diff --git a/editorial/agent-rewrites/233.json b/editorial/agent-rewrites/233.json index ce80b76..8ac5204 100644 --- a/editorial/agent-rewrites/233.json +++ b/editorial/agent-rewrites/233.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-07-mechanism-search-indexing", "title": "Индексация и поиск: почему сохранённая запись ещё не видна", "excerpt": "Запись уже открывается по прямой ссылке, но пропала из поиска. Разбираем путь source → ingest → index → refresh → query, проверяем устаревшую версию и выбираем безопасное действие.", - "contentHtml": "
Новая карточка открывается по прямой ссылке, но поиск её не возвращает. Или поиск показывает старый текст, хотя форма сохранения ответила успешно. Это наблюдаемый симптом, а не одна причина. Если сразу повторить запись, запустить полный reindex или включить принудительный refresh, система получит лишнюю нагрузку, а расследование потеряет исходную версию. В худшем случае появятся дубликаты: источник содержит одну запись, а индекс — старую и новую проекции.
\nГлавный тезис простой: сохранение и видимость в поиске — разные события. Source of truth отвечает за доменные данные. Индексатор строит поисковую проекцию. Refresh открывает подготовленные данные для search. Запрос проверяет ещё и поле, фильтр, область поиска и права. Пока мы не знаем, на каком переходе остановилась нужная версия, исправлять запрос рано.
\nДля карточки достаточно успешной записи в источнике. Для фоновой обработки нужен принятый event с идентификатором и версией. Для полнотекстового поиска нужен документ в индексе и момент, когда его сегмент стал видимым для search. Эти состояния нельзя заменить одним флагом saved. У них разные владельцы и разные проверки.
| Состояние | Что уже доказано | Чего ещё нет |
|---|---|---|
| source-of-truth | По id читается актуальный текст и version | Search ещё не обязан видеть запись |
| ingest queue | Есть задача построить проекцию для пары id:version | Индексатор ещё не применил задачу |
| pending index | Актуальный документ подготовлен индексатором | Видимый снимок поиска может оставаться старым |
| visible index | Документ доступен конкретному query | Это не доказывает работу другого alias, фильтра или окружения |
Такая схема не требует четырёх отдельных сервисов. В маленьком приложении несколько состояний могут жить в одном процессе. Граница всё равно существует: код должен различать «данные сохранены», «работа принята», «проекция подготовлена» и «поиск может вернуть документ». Если граница скрыта, команда принимает задержку за потерю данных или лечит фильтр повторной индексацией.
\nОдин идентификатор не описывает порядок изменений. Представим запись article-42. Сначала источник получает версию 6, затем версию 7. Event для версии 6 задержался в очереди. Если индексатор обработает его после версии 7 и не проверит номер, поиск снова покажет старый текст. Поэтому event должен нести как минимум id и version, а обработчик должен сравнивать event с текущим источником.
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};\nКод выше — учебный пример. В нём нет брокера, транзакции, конкурентных consumer-ов и долговечной очереди. Он показывает только порядок проверки: сначала найти источник, затем сравнить версию, затем подготовить проекцию. В реальном движке нужна его собственная политика конфликтов и повторов. Простая проверка в приложении не заменяет optimistic concurrency control, если несколько писателей меняют один документ одновременно.
\nКлюч article-42:7 также делает повтор заметным. Повторная доставка того же события не должна создавать новую смысловую запись. Это идемпотентность на границе ingest. Она не гарантирует порядок всех событий, поэтому version check остаётся обязательным. Нельзя считать повторный вызов доказательством исправления: сначала нужно выяснить, было ли исходное событие принято, обработано или отклонено как устаревшее.
Подготовленный документ может находиться в состоянии pending. Запрос к visible index в этот момент вернёт ноль результатов или старую версию. Refresh переносит подготовленные изменения в структуру, которую использует search. Он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа.
// Учебная модель: здесь 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) =>\n `${document.title} ${document.body}`.toLowerCase().includes(query.toLowerCase()),\n );\n}\nВ учебной модели refresh синхронен и бесплатен. В рабочем Elasticsearch это отдельный механизм. Текущая документация Elastic описывает near-real-time поиск: изменения становятся видимыми после refresh, а не в тот же момент, когда API записи вернул ответ. Поэтому параметр refresh=true нельзя превращать в безусловную кнопку на каждом write-path. Частые принудительные refresh увеличивают работу индекса и могут ухудшить пропускную способность.
Если пользовательский сценарий требует дождаться появления только что записанного документа, обычно проверяют контракт конкретного API и окружения. В Elasticsearch параметр refresh=wait_for ждёт обычного refresh и не обязан запускать немедленный refresh для каждой записи. Это не универсальная рекомендация для любого движка. Сначала нужно подтвердить версию, нагрузку и допустимую задержку в своём проекте.
Когда карточка открывается по id, это подтверждает путь чтения источника или realtime get. Когда текстовый поиск возвращает карточку, он подтверждает query path. Между ними могут отличаться индекс, alias, фильтры, анализ текста, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.
Для диагностики запишите точный запрос. Слово поиска, поле, фильтр, alias и окружение важнее общего сообщения «не находится». В учебной функции выше поиск — простая проверка подстроки. Он не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Успешный результат примера доказывает только причинную цепочку fixture, а не поведение настоящего анализатора.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
По id нет записи | Ошибка записи или неверный идентификатор | Сверить результат write, id и version в источнике | Остановиться. Не создавать копию для проверки |
| Источник есть, event ждёт | Задержка или отказ ingest | Найти ключ id:version и время постановки | Сохранить evidence и проверить consumer |
| Pending есть, search пуст | Refresh ещё не открыл проекцию | Сравнить время подготовки и видимости | Применить согласованную policy или локальный тест |
| Search возвращает старую version | Запоздалый event или конфликт порядка | Сравнить source.version, event.version и visible.version | Поставить текущую версию идемпотентно, сохранив след |
| Visible version актуальна, hit пуст | Ошибка query contract | Проверить scope, alias, filter, поле, анализатор и права | Исправлять запрос или mapping точечно |
Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары id:version. После этого проверяйте видимость. Только при актуальной видимой версии переходите к запросу. Если начать с reindex, можно потратить ресурсы и не заметить, что источник отсутствует или consumer получает старое событие.
Рассмотрим учебный сценарий. Источник сохранил article-42 с version 7. Event принят один раз. Индексатор подготовил version 7. До refresh query возвращает 0 hits. Это не доказывает потерю документа. Доказательства указывают на состояние awaiting-refresh. После refresh тот же query возвращает одну version 7.
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}\nУ этого примера нет production-результата. Значения времени, количество попаданий и имя состояния нужны, чтобы проверить порядок переходов в тесте. В настоящей системе вместо Map понадобятся журналы записи, метрики ingest, сведения о target индекса и безопасный запрос по тестовому документу. Не подставляйте условные миллисекунды в SLA.
id, source version, точный текстовый query, alias или индекс, фильтры и время наблюдения. Не меняйте данные до появления этого минимального следа.id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.Эта модель не описывает все свойства поисковой системы. Она не проверяет шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она также не говорит, какая задержка допустима для конкретного продукта. Эти решения зависят от движка, версии, нагрузки и пользовательского контракта.
\nРешение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ id:version; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельно должен существовать отрицательный тест: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.
Такой критерий не обещает мгновенный поиск. Он показывает, где проходит граница ответственности и какое действие можно выполнить без разрушения исходных данных. Сохранение записи остаётся фактом источника. Видимость в поиске становится отдельным проверяемым фактом.
\ntrue, false и wait_for и их влияния на стоимость и видимость.В июле 2021 года разработчик выкатывает новую карточку: форма отвечает успешно, а поиск не возвращает её по заголовку. Прямая ссылка открывает запись, поэтому первая гипотеза звучит просто: «поиск сломан». Наблюдаемый симптом ещё не объясняет, на какой границе возникла проблема. Это учебная сцена, а не отчёт о конкретном production-инциденте. Её цена понятна: повторная запись, полный reindex или безусловный refresh меняют систему до того, как сохранено исходное evidence.
\nСохранение и видимость в поиске — разные события. Source of truth хранит доменные данные, ingest принимает работу, индексатор строит поисковую проекцию, refresh открывает подготовленные сегменты для search, а query применяет поле, фильтр, область и права. Пока неизвестно, на какой границе остановилась нужная версия, исправлять запрос или запускать массовую индексацию рано.
\nУспешная запись доказывает только результат write-path. Для фоновой обработки нужен принятый event с идентификатором и версией. Для поиска нужен документ в индексе и видимый снимок этого индекса. В небольшом приложении несколько состояний могут жить в одном процессе, но границы всё равно должны быть различимы в коде и диагностике.
\n| Состояние | Что уже доказано | Чего ещё нет |
|---|---|---|
| source of truth | По id читается актуальный текст и version | Search ещё не обязан видеть запись |
| ingest queue | Есть работа для пары id:version | Индексатор ещё не применил её |
| pending index | Проекция подготовлена | Видимый снимок может оставаться старым |
| visible index | Документ доступен конкретному маршруту поиска | Это не доказывает работу другого alias, фильтра или окружения |
Такая модель не требует четырёх сервисов. Она требует четырёх проверяемых фактов. Если их свести к флагу saved, команда принимает задержку за потерю данных или лечит ошибку фильтра повторной индексацией. Одна запись с одним id обычно обновляет одну проекцию; дубликат появляется только при отдельной ошибке идентичности, например при разных ключах для одного доменного объекта.
Один идентификатор не описывает порядок изменений. Источник получает версию 6, затем версию 7, а событие для версии 6 задерживается в очереди. Если индексатор без проверки применит его после версии 7, поиск снова покажет старый текст. Поэтому event должен переносить как минимум id и version, а обработчик — сравнивать их с текущим источником.
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');\nЭтот фрагмент запускается в Node.js и проверяет только учебную модель: устаревшее событие отбрасывается, актуальное попадает в pending, до refresh поиск пуст, после refresh видна версия 7, повтор того же ключа подавляется. acceptedKeys — in-memory граница примера, а не гарантия брокера. В рабочей системе нужны долговечное хранение receipt, политика повторов и атомарное решение о порядке применения.
Проверка версии в приложении также не равна optimistic concurrency control. Elasticsearch поддерживает собственные варианты versioning и sequence numbers, но проект должен выбрать один источник порядка и один контракт записи. Нельзя одновременно молча полагаться на номер события, внутреннюю версию индекса и время доставки.
\nДокумент может уже находиться в pending index, пока запрос читает предыдущий видимый снимок. Refresh делает недавние операции доступными для search; он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа. Поэтому «сделали refresh — стало видно» доказывает только границу видимости, но не исправность всей цепочки.
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\nВ учебной модели refresh синхронен и бесплатен. В Elasticsearch это отдельный механизм near-real-time поиска: изменения становятся видимыми после refresh, а не обязательно в момент ответа API записи. Параметр refresh=true запрашивает немедленную видимость и может увеличить нагрузку. Параметр refresh=wait_for ждёт обычного refresh; он не является универсальной кнопкой «починить поиск».
Если продукту нужно дождаться видимости только что записанного документа, сначала зафиксируйте контракт конкретного API и окружения. Для одного пользовательского запроса ожидание может быть оправдано, а на массовом write-path — слишком дорогим. Частоту refresh, размер пакета и допустимую задержку нельзя переносить из документации или учебной модели без измерения.
\nКогда карточка открывается по id, это подтверждает путь чтения источника или realtime get. Когда текстовый запрос возвращает карточку, он подтверждает query path. Между маршрутами могут отличаться индекс, alias, анализ текста, фильтры, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.
Для диагностики запишите точный запрос: слово, поле, alias или индекс, фильтр, область, окружение и время. Учебная функция search проверяет подстроку и не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Её успешный результат доказывает причинную цепочку fixture, а не поведение настоящего анализатора.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
По id нет записи | Ошибка записи или неверный идентификатор | Сверить ответ write, id и version в источнике | Остановиться и не создавать копию |
| Источник есть, event ждёт | Задержка или отказ ingest | Найти ключ id:version и время постановки | Сохранить evidence и проверить consumer |
| Pending есть, search пуст | Refresh ещё не открыл проекцию | Сравнить время подготовки и видимости | Применить согласованную policy, не blind reindex |
| Search возвращает старую version | Запоздалый event или нарушение порядка | Сравнить source, event и visible version | Повторить только актуальную работу идемпотентно |
| Visible version актуальна, hit пуст | Ошибка query contract | Проверить поле, alias, analyzer, scope, filter и права | Исправлять запрос или mapping точечно |
Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары id:version. После этого проверяйте видимость. Только при актуальной видимой версии переходите к query. Если начать с reindex, можно потратить ресурсы и не заметить, что consumer получает старое событие.
Рассмотрим тот же учебный сценарий. Источник сохранил article-42 с version 7. Event принят и обработан, индексатор положил version 7 в pending. До refresh query возвращает ноль попаданий. Это не доказывает потерю документа: evidence указывает на состояние awaiting-refresh. После refresh тот же query возвращает один документ с version 7.
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 и не создаём второй документ.\nТеперь отрицательная ветка воспроизводима: функция получает явные состояния, возвращает безопасный диагноз и не маскирует отсутствие входных данных. В настоящей системе вместо объектов понадобятся журналы записи, метрики ingest, сведения о target индексе и запрос к тестовому документу. Условные этапы не являются названиями API и не заменяют проверку конкретного движка.
\nid, source version, точный текстовый query, alias или индекс, фильтры, область и время наблюдения. Не меняйте данные до появления этого минимального следа.id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.Эта модель не описывает шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она не задаёт допустимую задержку для конкретного продукта. Решения зависят от движка, версии, нагрузки, схемы данных и пользовательского контракта.
\nРешение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ id:version; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельный отрицательный тест должен показать: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.
Такой критерий не обещает мгновенный поиск. Он показывает границу ответственности и безопасное следующее действие. Сохранение записи остаётся фактом источника, а видимость в поиске становится отдельным проверяемым фактом.
\ntrue, false и wait_for, включая оговорки о нагрузке.refresh, versioning и порядку применения операций, подходящая для рамки июля 2021 года.