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Для расследования разделите путь на четыре состояния. source содержит текущую карточку. ingest содержит намерение построить проекцию для пары id:version. pending index содержит подготовленный документ, который ещё не возвращает обычный query. visible index содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.
| Состояние | Что уже доказано | Чего ещё нет |
|---|---|---|
source | Содержимое и версия сохранены | Поисковый запрос видит документ |
ingest | Поставлена работа для конкретной версии | Проекция обработана |
pending index | Документ подготовлен индексатором | Он доступен обычному query |
visible index | Запрос может вернуть эту версию | Другие фильтры и области поиска корректны |
Версия нужна для порядка обновлений. Событие с version: 6 не должно молча переписать документ, который source уже поднял до версии 7. Ключ article-17:7 задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.
Ниже приведён ограниченный пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Он показывает только контракт переходов: источник сохраняется, ingest дедуплицируется, индексатор проверяет версию, а query начинает видеть документ после отдельного refresh.
\nconst 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. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.
\nfunction 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 в универсальную кнопку после каждого изменения.
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. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Карточка не открывается по id | Source не сохранён или читается не тот владелец | Прочитать 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 и удалить дубликаты отдельной процедурой |
Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.
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 с ошибкой фильтра.
\nid:version и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.
\nЯвный refresh способен увеличить нагрузку. Официальная документация Elasticsearch описывает refresh=false как режим без действий refresh, wait_for как ожидание ближайшего refresh, а true как немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.
Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.
\nСценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.
\ntrue, false и wait_for и предупреждает о стоимости немедленного refresh.Карточка уже открывается по прямой ссылке, но поиск по её заголовку возвращает пустой результат. Иногда пользователь видит старое название. Иногда один документ появляется дважды. Цена ошибки быстро растёт: редактор повторяет сохранение, оператор запускает повторную индексацию, а система получает дубликаты и лишнюю нагрузку. При этом исходная запись могла сохраниться правильно.
\nТезис простой: запись в source и видимость в поисковой выдаче — разные события. Между ними стоят ingest, обработчик, индексная проекция и переход видимости. У каждого шага должен быть свой признак успеха. Если свести всё к флагу saved, причина исчезнувшего результата останется неизвестной.
Источник данных владеет содержимым и версией документа. Поисковая проекция хранит форму, удобную для запроса: нормализованный текст, поля фильтра и идентификатор. Проекция не заменяет источник. Она строится после изменения источника и может стать видимой позже.
\nДля расследования разделите путь на четыре состояния. source содержит текущую карточку. ingest содержит намерение построить проекцию для пары id:version. pending index содержит подготовленный документ, который ещё не возвращает обычный query. visible index содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.
| Состояние | Что уже доказано | Чего ещё нет |
|---|---|---|
source | Содержимое и версия сохранены | Поисковый запрос видит документ |
ingest | Поставлена работа для конкретной версии | Проекция обработана |
pending index | Документ подготовлен индексатором | Он доступен обычному query |
visible index | Запрос может вернуть эту версию | Другие фильтры и области поиска корректны |
Версия нужна для порядка обновлений. Событие с version: 6 не должно молча переписать документ, который source уже поднял до версии 7. Ключ article-17:7 задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.
Ниже приведён ограниченный, но сквозной пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Все четыре фрагмента нужно выполнить в одном Node.js-сеансе сверху вниз: сначала создаются хранилища и функции, затем документ сохраняется, индексируется и проверяется до и после refresh. Заголовок входит в текст проекции специально: это делает воспроизводимым заявленный в начале поиск по заголовку.
\nconst 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. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.
\nfunction 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 в универсальную кнопку после каждого изменения.
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 не создаёт вторую работу.
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 и как повторяется задача после временной ошибки.
Если прямая ссылка не открывает карточку, начинать с refresh нельзя. Сначала проверьте source. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Карточка не открывается по id | Source не сохранён или читается не тот владелец | Прочитать 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 и удалить дубликаты отдельной процедурой |
Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.
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 с ошибкой фильтра.
\nid:version и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.
\nВ документации Elasticsearch 7.13 параметр refresh=false означает отсутствие действия refresh, wait_for — ожидание ближайшего refresh, а true — немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.
Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.
\nСценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.
\ntrue, false и wait_for и предупреждает о стоимости немедленного refresh.