diff --git a/editorial/agent-rewrites/232.json b/editorial/agent-rewrites/232.json index 9eeb4bb..d29c5d6 100644 --- a/editorial/agent-rewrites/232.json +++ b/editorial/agent-rewrites/232.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-07-field-search-indexing", "title": "Документ сохранён, но не найден: как отделить задержку индекса от ошибки запроса", "excerpt": "Карточка уже открывается, но поиск не возвращает новый документ. Разбираем путь от source до видимой выдачи, проверяем version и refresh и выбираем безопасное действие без удаления исходной записи.", - "contentHtml": "
Новый товар сохранён: карточка открывается по id, но поиск по названию возвращает пустой список. Через несколько минут товар появляется сам. Ошибка выглядит временной, но её цена вполне материальна: пользователь не находит доступный товар, оператор запускает дорогую повторную индексацию, а удаление и повторное создание записи стирают след исходного состояния. После такого вмешательства уже трудно понять, не была ли причина в запросе, фильтре или правах.
\nГлавный тезис прост: source и search отвечают на разные вопросы. Успешное чтение записи по id доказывает, что источник существует. Оно не доказывает, что текущая версия уже попала в поисковую видимую проекцию. Пустой search до refresh может быть нормальной задержкой. Пустой search после подтверждённой видимости указывает на другой участок: поле, анализатор, filter, scope или права.
\nПолезно разложить путь документа на состояния. source хранит исходную запись и её version. ingest принимает работу на построение поисковой проекции. pending содержит подготовленную версию, которую ещё не видит обычный search. visible — снимок, по которому выполняется запрос. Между состояниями есть переходы. Каждый переход имеет свой ключ, время и результат.
Предположим, source содержит документ product-42 с version 7. Ingest получает ключ product-42:7. Повторная постановка с тем же ключом не должна создавать вторую работу. После обработки version 7 может оказаться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если видимая version равна 7, а query по-прежнему пуст, refresh уже не объясняет симптом.
source(version=7)\n -> ingest(key="product-42:7")\n -> pending(version=7)\n -> refresh\n -> visible(version=7)\n -> search(query, scope, filter)\n\nПравило диагностики:\nsource read != search hit\nVersion нужна не для красоты. Она связывает источник, работу и проекцию. Если лог содержит только название товара, две последовательные правки выглядят одинаково. Если лог содержит id:version, можно увидеть, что старая работа не должна перезаписать новую, а повторная постановка относится к той же версии. Timestamp помогает оценить задержку, но не заменяет порядок версий.
Ниже — минимальная модель. Она не подключается к Elasticsearch или базе и не описывает производительность. Код показывает только причинную цепочку: документ уже есть в source, затем появляется в pending, но search начинает возвращать его лишь после явного перехода видимости.
\nconst source = new Map();\nconst pending = new Map();\nconst visible = new Map();\n\nsource.set('product-42', {\n id: 'product-42',\n version: 7,\n title: 'Свежие яблоки',\n});\n\npending.set('product-42', source.get('product-42'));\n\nfunction search(term) {\n return [...visible.values()].filter((doc) =>\n doc.title.toLowerCase().includes(term.toLowerCase()),\n );\n}\n\nconsole.log(search('яблоки')); // []\n\nfor (const [id, doc] of pending) {\n visible.set(id, doc);\n}\n\nconsole.log(search('яблоки')); // [{ id: 'product-42', version: 7, ... }]\nВ реальном движке переход видимости может происходить автоматически по настройке refresh interval или по явной операции. Его стоимость и охват зависят от версии, размера индекса и нагрузки. Поэтому учебный вызов visible.set нельзя переносить в production как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| По id source отсутствует | Ошибка записи или неверный id | Сверить id, version и результат сохранения | Остановиться. Не создавать копию до проверки write path |
| Source есть, ingest не подтверждён | Работа не поставлена или потеряна | Найти ключ id:version и результат постановки | Сохранить evidence и проверить очередь по её policy |
| Pending содержит текущую version, search пуст | Проекция ещё не стала видимой | Сравнить время обработки и refresh или признак видимости | Применить согласованную policy. Не удалять source |
| Visible содержит старую version | Устаревшая работа или нарушение порядка | Сравнить source.version, event.version и visible.version | Поставить текущую version идемпотентно и сохранить старый след |
| Visible содержит текущую version, search пуст | Ошибка query contract | Проверить поле, analyzer, filter, scope, routing и права | Исправить только найденный участок и повторить исходный query |
Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.
\nСначала зафиксируйте точный query: индекс или alias, поле, текст, фильтры, сортировку, routing и права. Затем получите source по id и запишите version. После этого найдите ту же version в состоянии проекции. Если она находится только в pending, причина ещё находится до refresh. Если она видима, повторная постановка не добавит новых фактов.
\nПроверяйте отрицательный путь отдельно. Для документа version 7 ожидайте пустой search до подтверждённого refresh. После refresh ожидайте ровно один hit с version 7. Для повторного ingest с ключом product-42:7 ожидайте подавление дубликата. Для старой version 6 ожидайте отказ от отката видимого документа. Эти ожидания проверяют механизм, но не обещают SLA, hit rate или поведение кластера.
Особенно опасно считать Get API и search одним чтением. В Elasticsearch документ можно получить по id раньше, чем он становится доступен обычному поиску. Эта разница полезна для диагностики, но она не доказывает, что пользовательский query исправен. Пользователь видит не внутренний Get, а выдачу с её анализатором, фильтрами и ограничениями доступа.
\nid:version. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.
\nНе каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.
\nДиагностика готова, когда для одного тестового документа можно показать непрерывную историю id → version → ingest key → pending или visible → точный query → результат. В истории есть отрицательный случай до refresh, положительный случай после него, подавление дубликата и отказ от отката старой version. Исправление считается доказанным только тогда, когда исходный query возвращает ожидаемый id и текущую version, а запись не удаляли ради получения этого результата.
Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.
\nСимптом на стенде знаком: оператор сохраняет новый товар: карточка открывается по id, но поиск по названию возвращает пустой список. Через несколько минут товар появляется сам. Сценарий выглядит временным, но его цена вполне материальна: пользователь не находит доступный товар, оператор запускает дорогую повторную индексацию, а удаление и повторное создание записи стирают след исходного состояния. После такого вмешательства уже трудно понять, не была ли причина в запросе, фильтре или правах.
\nРабочее правило такое: source и search отвечают на разные вопросы. Успешное чтение записи по id доказывает, что источник существует. Оно не доказывает, что текущая версия уже попала в видимую поисковую проекцию. Пустой search до refresh может быть нормальной задержкой. Пустой search после подтверждения видимости указывает на другой участок: поле, анализатор, filter, scope или права.
\nДля диагностики удобно разложить путь документа на четыре границы: source хранит исходную запись и её version; ingest принимает событие на построение проекции; pending содержит подготовленную версию, которую ещё не видит обычный search; visible — снимок, по которому выполняется запрос. Между границами есть переходы. Каждый переход нужно связывать с id, version, временем и результатом.
Это абстрактная модель потока, а не четыре обязательных статуса Elasticsearch. В конкретной системе ingest может быть очередью, воркером или Ingest Node pipeline, а pending — сообщением в очереди, staging-индексом или буфером до refresh. Эти детали нельзя угадывать по одному пустому ответу: их нужно сопоставить с реальной схемой доставки.
Предположим, source содержит документ product-42 с версией 7. Событие получает ключ product-42:7. Повторная постановка с тем же ключом не должна создавать вторую работу, если это правило закреплено в контракте обработчика. После обработки версия 7 может находиться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если та же версия уже видима в том же индексе и с тем же routing, refresh больше не объясняет пустой результат.
source(version=7)\n -> ingest(key="product-42:7")\n -> pending(version=7)\n -> refresh\n -> visible(version=7)\n -> search(query, scope, filter)\n\nПравило диагностики:\nsource read != search hit\nVersion связывает источник, работу и проекцию, но сама по себе не делает очередь идемпотентной. Ключ id:version — это договорённость приложения: её должен поддерживать consumer или хранилище событий. В Elasticsearch не следует автоматически принимать поле source version за служебное _version: внутренний номер документа и версия из исходной системы решают разные задачи. Для защиты от устаревших изменений подходят внешний versioning или optimistic concurrency control с if_seq_no и if_primary_term — выбор зависит от того, кто владеет порядком изменений.
Ниже — минимальная модель без Elasticsearch, базы и сетевых задержек. Она намеренно показывает три проверяемых свойства: одинаковый ключ не создаёт вторую работу, старая версия не заменяет новую, а поиск видит принятую версию только после refresh. Код можно сохранить как файл demo.mjs и запустить командой node demo.mjs; внешние пакеты не нужны.
const source = new Map([\n ['product-42', { id: 'product-42', version: 7, title: 'Свежие яблоки' }],\n]);\nconst pending = new Map();\nconst visible = new Map();\nconst acceptedKeys = new Set();\n\nfunction ingest(doc) {\n const key = doc.id + ':' + doc.version;\n if (acceptedKeys.has(key)) return { status: 'duplicate', key };\n\n const current = pending.get(doc.id) ?? visible.get(doc.id);\n if (current && current.version >= doc.version) {\n return { status: 'stale', key };\n }\n\n acceptedKeys.add(key);\n pending.set(doc.id, doc);\n return { status: 'queued', key };\n}\n\nfunction refresh() {\n for (const [id, doc] of pending) {\n const current = visible.get(id);\n if (!current || current.version <= doc.version) visible.set(id, doc);\n pending.delete(id);\n }\n}\n\nfunction search(term) {\n return [...visible.values()].filter((doc) =>\n doc.title.toLowerCase().includes(term.toLowerCase()),\n );\n}\n\nconsole.log(search('яблоки')); // []\nconsole.log(ingest(source.get('product-42'))); // queued, product-42:7\nconsole.log(search('яблоки')); // []\nrefresh();\nconsole.log(search('яблоки')); // [{ id: 'product-42', version: 7, ... }]\nconsole.log(ingest(source.get('product-42'))); // duplicate\nconsole.log(ingest({ id: 'product-42', version: 6, title: 'Старые яблоки' })); // stale\nconsole.log(visible.get('product-42').version); // 7\nЗдесь visible — не внутренний объект Elasticsearch, а тестовый снимок. В реальном Elasticsearch операция записи с refresh=false не обязана сразу менять поисковую выдачу; refresh=wait_for ждёт очередного обновления, а refresh=true принудительно обновляет затронутые shards и требует оценки нагрузки. Поэтому вызов visible.set нельзя переносить в production как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| По id source отсутствует | Ошибка записи или неверный id | Сверить id, version и результат сохранения | Остановиться. Не создавать копию до проверки write path |
| Source есть, ingest не подтверждён | Работа не поставлена или потеряна | Найти ключ id:version и результат постановки | Сохранить evidence и проверить очередь по её policy |
| Pending содержит текущую version, search пуст | Проекция ещё не стала видимой | Сравнить время обработки и refresh или признак видимости | Применить согласованную policy. Не удалять source |
| Visible содержит старую version | Устаревшая работа или нарушение порядка | Сравнить source.version, event.version и visible.version | Поставить текущую version идемпотентно и сохранить старый след |
| Visible содержит текущую version, search пуст | Ошибка query contract | Проверить поле, analyzer, filter, scope, routing и права | Исправить только найденный участок и повторить исходный query |
Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.
\nСначала зафиксируйте точный query: индекс или alias, поле, текст, фильтры, сортировку, routing и права. Затем получите source по id и запишите версию. В Elasticsearch Get API по умолчанию работает в realtime-режиме и не зависит от refresh rate индекса, тогда как обычный search видит изменения после refresh. Поэтому результат Get нельзя использовать как доказательство готовности поисковой выдачи.
\nПосле Get найдите ту же версию в состоянии проекции и проверьте, что это тот же индекс, alias, shard и routing. Если версия находится только в pending, причина ещё находится до refresh. Если текущая версия действительно видима, проверьте mapping, анализатор, нормализацию, filter, scope и права. При использовании служебной версии запроса сохраните также _seq_no и _primary_term, чтобы отличить конфликт обновления от задержки поиска.
Проверяйте отрицательный путь отдельно. Для документа version 7 ожидайте пустой search до подтверждённого refresh. После refresh ожидайте ровно один hit с version 7. Для повторного ingest с ключом product-42:7 ожидайте подавление дубликата. Для старой version 6 ожидайте отказ от отката видимого документа. Эти ожидания проверяют механизм, но не обещают SLA, hit rate или поведение кластера.
id:version. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.
\nНе каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.
\nДиагностика готова, когда для одного тестового документа можно показать непрерывную историю id → version → ingest key → pending или visible → точный query → результат. В истории есть отрицательный случай до refresh, положительный случай после него, подавление дубликата и отказ от отката старой version. Исправление считается доказанным только тогда, когда исходный query возвращает ожидаемый id и текущую version, а запись не удаляли ради получения этого результата.
Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.
\nrefresh=false, refresh=wait_for, refresh=true и стоимость принудительного обновления._version, _seq_no и _primary_term.