diff --git a/editorial/agent-rewrites/234.json b/editorial/agent-rewrites/234.json index c978de6..0c24f04 100644 --- a/editorial/agent-rewrites/234.json +++ b/editorial/agent-rewrites/234.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-07-practice-search-indexing", "title": "Индексация и поиск: почему сохранённая запись ещё не видна", "excerpt": "Карточка открывается по прямой ссылке, но поиск возвращает пустой результат или старую версию. Разбираем границы source, ingest, индексирования и refresh, а затем показываем проверяемый маршрут диагностики.", - "contentHtml": "

Карточка уже открывается по прямой ссылке, но поиск по её заголовку возвращает пустой результат. Иногда пользователь видит старое название. Иногда один документ появляется дважды. Цена ошибки быстро растёт: редактор повторяет сохранение, оператор запускает повторную индексацию, а система получает дубликаты и лишнюю нагрузку. При этом исходная запись могла сохраниться правильно.

\n

Тезис простой: запись в source и видимость в поисковой выдаче — разные события. Между ними стоят ingest, обработчик, индексная проекция и переход видимости. У каждого шага должен быть свой признак успеха. Если свести всё к флагу saved, причина исчезнувшего результата останется неизвестной.

\n

Механизм: источник, проекция и запрос

\n

Источник данных владеет содержимым и версией документа. Поисковая проекция хранит форму, удобную для запроса: нормализованный текст, поля фильтра и идентификатор. Проекция не заменяет источник. Она строится после изменения источника и может стать видимой позже.

\n

Для расследования разделите путь на четыре состояния. source содержит текущую карточку. ingest содержит намерение построить проекцию для пары id:version. pending index содержит подготовленный документ, который ещё не возвращает обычный query. visible index содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.

\n
Состояния одной карточки
СостояниеЧто уже доказаноЧего ещё нет
sourceСодержимое и версия сохраненыПоисковый запрос видит документ
ingestПоставлена работа для конкретной версииПроекция обработана
pending indexДокумент подготовлен индексаторомОн доступен обычному query
visible indexЗапрос может вернуть эту версиюДругие фильтры и области поиска корректны
\n

Версия нужна для порядка обновлений. Событие с version: 6 не должно молча переписать документ, который source уже поднял до версии 7. Ключ article-17:7 задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.

\n

Учебный пример на JavaScript

\n

Ниже приведён ограниченный пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Он показывает только контракт переходов: источник сохраняется, ingest дедуплицируется, индексатор проверяет версию, а query начинает видеть документ после отдельного refresh.

\n
const source = new Map(); const acceptedIngest = new Set(); const pending = new Map(); const visible = new Map(); function saveArticle(article, savedAtMs) { source.set(article.id, { ...article, savedAtMs }); return { id: article.id, version: article.version }; } function enqueueArticle(id, version, enqueuedAtMs) { const key = `${id}:${version}`; if (acceptedIngest.has(key)) return { status: 'duplicate', key }; acceptedIngest.add(key); pending.set(key, { id, version, enqueuedAtMs }); return { status: 'queued', key }; }
\n

Обработчик сначала читает текущий source. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.

\n
function consumeArticle(id, version, indexedAtMs) { const article = source.get(id); if (!article || article.version !== version) return { status: 'stale-or-missing', id, version }; const key = `${id}:${version}`; pending.set(key, { id, version, title: article.title, text: article.body.toLowerCase(), indexedAtMs }); return { status: 'prepared', key }; }
\n

На этом месте документ ещё не обязан находиться в выдаче. Учебный refreshSearch переносит подготовленную проекцию в видимую. В реальном движке название и стоимость операции зависят от версии, настроек и нагрузки. Поэтому нельзя превращать явный refresh в универсальную кнопку после каждого изменения.

\n
function refreshSearch(visibleAtMs) { for (const [key, document] of pending) visible.set(key, { ...document, visibleAtMs }); pending.clear(); } function searchArticles(term) { const needle = term.toLowerCase(); return [...visible.values()].filter((document) => document.text.includes(needle)); }
\n
Путь документа от source-of-truth через ingest и pending index к visible index и поисковому запросу
Сохранение источника и видимость в выдаче разделены явным переходом. Схема иллюстрирует учебную модель, а не устройство конкретного кластера.
\n

Как читать симптом

\n

Если прямая ссылка не открывает карточку, начинать с refresh нельзя. Сначала проверьте source. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Карточка не открывается по idSource не сохранён или читается не тот владелецПрочитать source по id и сравнить versionИсправить запись или маршрут чтения; не запускать reindex
Source есть, ingest нетНе создано событие или потерян переход writer → ingestСопоставить запись и ключ id:versionВосстановить постановку по согласованной политике и добавить наблюдение
Ingest есть, pending пустОбработчик упал, пропустил задачу или отклонил старую versionПроверить статус consume и текущую version sourceРазобрать ошибку; не считать повтор записи лечением
Pending есть, query пустПроекция ещё не стала видимойСравнить indexedAtMs и visibleAtMsДождаться штатного refresh или применить документированный режим ожидания
Visible есть, результата нетФильтр, поле, анализатор или область запроса не совпадаетВыполнить минимальный query без лишнего фильтраИсправить контракт запроса, mapping или данные
Две карточки с одним смысломПовтор обработан как новый документ или ключ не учитывает versionСравнить document id, ingest key и версииЗакрепить idempotency key и удалить дубликаты отдельной процедурой
\n

Задержка должна иметь точки измерения

\n

Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.

\n
const timeline = { savedAtMs: 100, enqueuedAtMs: 110, indexedAtMs: 125, visibleAtMs: 160 }; const totalDelay = timeline.visibleAtMs - timeline.savedAtMs; const visibilityDelay = timeline.visibleAtMs - timeline.indexedAtMs; // totalDelay === 60; числа выбраны для учебной арифметики
\n

Эти числа не являются замером и не задают SLA. В рабочем коде время должно приходить из событий и логов, а не из fixture. Для пользователя может быть важнее доля запросов, которые видят актуальную version, чем средняя задержка. Выберите метрику по сценарию. Не смешивайте задержку pipeline с ошибкой фильтра.

\n

Порядок действий

\n
  1. Выберите один конкретный сценарий: например, поиск опубликованной статьи по заголовку.
  2. Запишите source id, текущую version и момент сохранения. Проверьте прямое чтение.
  3. Найдите ingest key id:version и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.
  4. Проверьте обработчик: он должен принять текущую version и отклонить устаревшее событие с понятным статусом.
  5. Снимите время подготовки pending-проекции. Не называйте её видимой, пока query этого не подтверждает.
  6. Проверьте политику refresh выбранного движка. Для синхронного сценария используйте только документированный режим и оцените его стоимость.
  7. Повторите тот же query после перехода видимости и сравните id, version, фильтры и число результатов.
  8. Если visible уже содержит документ, прекратите повторную индексацию и расследуйте контракт запроса.
\n

Ограничения и отрицательный путь

\n

Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.

\n

Явный refresh способен увеличить нагрузку. Официальная документация Elasticsearch описывает refresh=false как режим без действий refresh, wait_for как ожидание ближайшего refresh, а true как немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.

\n

Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.

\n

Проверяемые источники

\n" + "contentHtml": "

Карточка уже открывается по прямой ссылке, но поиск по её заголовку возвращает пустой результат. Иногда пользователь видит старое название. Иногда один документ появляется дважды. Цена ошибки быстро растёт: редактор повторяет сохранение, оператор запускает повторную индексацию, а система получает дубликаты и лишнюю нагрузку. При этом исходная запись могла сохраниться правильно.

\n

Тезис простой: запись в source и видимость в поисковой выдаче — разные события. Между ними стоят ingest, обработчик, индексная проекция и переход видимости. У каждого шага должен быть свой признак успеха. Если свести всё к флагу saved, причина исчезнувшего результата останется неизвестной.

\n

Механизм: источник, проекция и запрос

\n

Источник данных владеет содержимым и версией документа. Поисковая проекция хранит форму, удобную для запроса: нормализованный текст, поля фильтра и идентификатор. Проекция не заменяет источник. Она строится после изменения источника и может стать видимой позже.

\n

Для расследования разделите путь на четыре состояния. source содержит текущую карточку. ingest содержит намерение построить проекцию для пары id:version. pending index содержит подготовленный документ, который ещё не возвращает обычный query. visible index содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.

\n
Состояния одной карточки
СостояниеЧто уже доказаноЧего ещё нет
sourceСодержимое и версия сохраненыПоисковый запрос видит документ
ingestПоставлена работа для конкретной версииПроекция обработана
pending indexДокумент подготовлен индексаторомОн доступен обычному query
visible indexЗапрос может вернуть эту версиюДругие фильтры и области поиска корректны
\n

Версия нужна для порядка обновлений. Событие с version: 6 не должно молча переписать документ, который source уже поднял до версии 7. Ключ article-17:7 задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.

\n

Учебный пример на JavaScript

\n

Ниже приведён ограниченный, но сквозной пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Все четыре фрагмента нужно выполнить в одном Node.js-сеансе сверху вниз: сначала создаются хранилища и функции, затем документ сохраняется, индексируется и проверяется до и после refresh. Заголовок входит в текст проекции специально: это делает воспроизводимым заявленный в начале поиск по заголовку.

\n
const source = new Map(); const acceptedIngest = new Set(); const pending = new Map(); const visible = new Map(); function saveArticle(article, savedAtMs) { source.set(article.id, { ...article, savedAtMs }); return { id: article.id, version: article.version }; } function enqueueArticle(id, version, enqueuedAtMs) { const key = `${id}:${version}`; if (acceptedIngest.has(key)) return { status: 'duplicate-ingest-suppressed', key }; acceptedIngest.add(key); pending.set(key, { id, version, enqueuedAtMs }); return { status: 'queued', key }; }
\n

Обработчик сначала читает текущий source. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.

\n
function consumeArticle(id, version, indexedAtMs) { const article = source.get(id); if (!article || article.version !== version) return { status: 'stale-or-missing', id, version }; const key = `${id}:${version}`; pending.set(key, { id, version, title: article.title, text: `${article.title} ${article.body}`.toLowerCase(), indexedAtMs }); return { status: 'prepared', key }; }
\n

На этом месте документ ещё не обязан находиться в выдаче. Учебный refreshSearch переносит подготовленную проекцию в видимую. В реальном движке название и стоимость операции зависят от версии, настроек и нагрузки. Поэтому нельзя превращать явный refresh в универсальную кнопку после каждого изменения.

\n
function refreshSearch(visibleAtMs) { for (const [key, document] of pending) visible.set(key, { ...document, visibleAtMs }); pending.clear(); } function searchArticles(term) { const needle = term.toLowerCase(); return [...visible.values()].filter((document) => document.text.includes(needle)); }
\n

Теперь запустите один сценарий с фиксированными входными данными. До refresh запрос по слову «индексация» должен вернуть ноль документов, хотя source и pending уже заполнены. После refresh тот же запрос должен вернуть одну карточку с версией 7. Повторная постановка того же id:version не создаёт вторую работу.

\n
const article = { id: 'article-17', version: 7, title: 'Индексация и поиск', body: 'Проекция становится видимой после refresh.' }; const saved = saveArticle(article, 100); const queued = enqueueArticle(saved.id, saved.version, 110); const duplicate = enqueueArticle(saved.id, saved.version, 111); const prepared = consumeArticle(saved.id, saved.version, 125); const beforeRefresh = searchArticles('индексация'); const pendingBeforeRefresh = pending.size; refreshSearch(160); const afterRefresh = searchArticles('индексация'); console.log({ saved, queued, duplicate, prepared, beforeRefresh: beforeRefresh.length, pendingBeforeRefresh, afterRefresh: afterRefresh.length, visibleVersion: afterRefresh[0]?.version }); // beforeRefresh: 0, pendingBeforeRefresh: 1, afterRefresh: 1, visibleVersion: 7
\n

У примера есть намеренная граница: acceptedIngest — только in-memory receipt-set. Он показывает, как назвать ключ и подавить повтор внутри одной модели, но не доказывает идемпотентность реального producer, broker или API. В рабочем проекте нужно отдельно решить, где живёт такой ключ, сколько хранится receipt и как повторяется задача после временной ошибки.

\n
Путь документа от source-of-truth через ingest и pending index к visible index и поисковому запросу
Сохранение источника и видимость в выдаче разделены явным переходом. Схема иллюстрирует учебную модель, а не устройство конкретного кластера.
\n

Как читать симптом

\n

Если прямая ссылка не открывает карточку, начинать с refresh нельзя. Сначала проверьте source. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Карточка не открывается по idSource не сохранён или читается не тот владелецПрочитать source по id и сравнить versionИсправить запись или маршрут чтения; не запускать reindex
Source есть, ingest нетНе создано событие или потерян переход writer → ingestСопоставить запись и ключ id:versionВосстановить постановку по согласованной политике и добавить наблюдение
Ingest есть, pending пустОбработчик упал, пропустил задачу или отклонил старую versionПроверить статус consume и текущую version sourceРазобрать ошибку; не считать повтор записи лечением
Pending есть, query пустПроекция ещё не стала видимойСравнить indexedAtMs и visibleAtMsДождаться штатного refresh или применить документированный режим ожидания
Visible есть, результата нетФильтр, поле, анализатор или область запроса не совпадаетВыполнить минимальный query без лишнего фильтраИсправить контракт запроса, mapping или данные
Две карточки с одним смысломПовтор обработан как новый документ или ключ не учитывает versionСравнить document id, ingest key и версииЗакрепить idempotency key и удалить дубликаты отдельной процедурой
\n

Задержка должна иметь точки измерения

\n

Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.

\n
const timeline = { savedAtMs: 100, enqueuedAtMs: 110, indexedAtMs: 125, visibleAtMs: 160 }; const totalDelay = timeline.visibleAtMs - timeline.savedAtMs; const visibilityDelay = timeline.visibleAtMs - timeline.indexedAtMs; // totalDelay === 60; числа выбраны для учебной арифметики
\n

Эти числа не являются замером и не задают SLA. В рабочем коде время должно приходить из событий и логов, а не из fixture. Для пользователя может быть важнее доля запросов, которые видят актуальную version, чем средняя задержка. Выберите метрику по сценарию. Не смешивайте задержку pipeline с ошибкой фильтра.

\n

Порядок действий

\n
  1. Выберите один конкретный сценарий: например, поиск опубликованной статьи по заголовку.
  2. Запишите source id, текущую version и момент сохранения. Проверьте прямое чтение.
  3. Найдите ingest key id:version и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.
  4. Проверьте обработчик: он должен принять текущую version и отклонить устаревшее событие с понятным статусом.
  5. Снимите время подготовки pending-проекции. Не называйте её видимой, пока query этого не подтверждает.
  6. Проверьте политику refresh выбранного движка. Для синхронного сценария используйте только документированный режим и оцените его стоимость.
  7. Повторите тот же query после перехода видимости и сравните id, version, фильтры и число результатов.
  8. Если visible уже содержит документ, прекратите повторную индексацию и расследуйте контракт запроса.
\n

Ограничения и отрицательный путь

\n

Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.

\n

В документации Elasticsearch 7.13 параметр refresh=false означает отсутствие действия refresh, wait_for — ожидание ближайшего refresh, а true — немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.

\n

Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.

\n

Проверяемые источники

\n" }