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

Механизм: четыре состояния вместо одного «индекса»

\n

Полезно разложить путь документа на состояния. source хранит исходную запись и её version. ingest принимает работу на построение поисковой проекции. pending содержит подготовленную версию, которую ещё не видит обычный search. visible — снимок, по которому выполняется запрос. Между состояниями есть переходы. Каждый переход имеет свой ключ, время и результат.

\n

Предположим, source содержит документ product-42 с version 7. Ingest получает ключ product-42:7. Повторная постановка с тем же ключом не должна создавать вторую работу. После обработки version 7 может оказаться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если видимая version равна 7, а query по-прежнему пуст, refresh уже не объясняет симптом.

\n
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
\n

Version нужна не для красоты. Она связывает источник, работу и проекцию. Если лог содержит только название товара, две последовательные правки выглядят одинаково. Если лог содержит id:version, можно увидеть, что старая работа не должна перезаписать новую, а повторная постановка относится к той же версии. Timestamp помогает оценить задержку, но не заменяет порядок версий.

\n

Учебный пример: запрос до и после refresh

\n

Ниже — минимальная модель. Она не подключается к Elasticsearch или базе и не описывает производительность. Код показывает только причинную цепочку: документ уже есть в source, затем появляется в pending, но search начинает возвращать его лишь после явного перехода видимости.

\n
const 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 как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».

\n
\"Дерево
Проверяйте путь от source к query по границам. Если текущая version уже видима, переходите к контракту запроса.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
По 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
\n

Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.

\n

Как отличить задержку видимости от ошибки запроса

\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 или поведение кластера.

\n

Особенно опасно считать Get API и search одним чтением. В Elasticsearch документ можно получить по id раньше, чем он становится доступен обычному поиску. Эта разница полезна для диагностики, но она не доказывает, что пользовательский query исправен. Пользователь видит не внутренний Get, а выдачу с её анализатором, фильтрами и ограничениями доступа.

\n

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

\n
  1. Запишите id, source version, точный query, индекс или alias, параметры фильтра и время наблюдения. Уберите из evidence секреты и лишние персональные данные.
  2. Проверьте source по id. Если записи нет, остановите поисковое расследование и выясните write path. Не создавайте дубликат для проверки.
  3. Проверьте ключ ingest в формате id:version. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.
  4. Найдите version в pending или видимой проекции. Если текущая version ждёт refresh, зафиксируйте время и примените только заранее выбранную policy.
  5. Если видима старая version, сравните порядок версий и найдите источник старой работы. Повторную постановку делайте идемпотентной и не стирайте исходное evidence.
  6. Если видима текущая version, проверьте query contract: поле, mapping, нормализацию текста, analyzer, filter, scope, routing и права.
  7. Повторите исходный query без дополнительных изменений. Сравните id и version в результате. Затем добавьте проверку на отрицательный путь в тест или runbook проекта.
\n

Ограничения

\n

Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.

\n

Не каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.

\n

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

\n

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

\n

Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.

\n

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

\n" + "contentHtml": "

Симптом на стенде знаком: оператор сохраняет новый товар: карточка открывается по id, но поиск по названию возвращает пустой список. Через несколько минут товар появляется сам. Сценарий выглядит временным, но его цена вполне материальна: пользователь не находит доступный товар, оператор запускает дорогую повторную индексацию, а удаление и повторное создание записи стирают след исходного состояния. После такого вмешательства уже трудно понять, не была ли причина в запросе, фильтре или правах.

\n

Рабочее правило такое: source и search отвечают на разные вопросы. Успешное чтение записи по id доказывает, что источник существует. Оно не доказывает, что текущая версия уже попала в видимую поисковую проекцию. Пустой search до refresh может быть нормальной задержкой. Пустой search после подтверждения видимости указывает на другой участок: поле, анализатор, filter, scope или права.

\n

Механизм: четыре границы вместо одного «индекса»

\n

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

\n

Это абстрактная модель потока, а не четыре обязательных статуса Elasticsearch. В конкретной системе ingest может быть очередью, воркером или Ingest Node pipeline, а pending — сообщением в очереди, staging-индексом или буфером до refresh. Эти детали нельзя угадывать по одному пустому ответу: их нужно сопоставить с реальной схемой доставки.

\n

Предположим, source содержит документ product-42 с версией 7. Событие получает ключ product-42:7. Повторная постановка с тем же ключом не должна создавать вторую работу, если это правило закреплено в контракте обработчика. После обработки версия 7 может находиться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если та же версия уже видима в том же индексе и с тем же routing, refresh больше не объясняет пустой результат.

\n
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
\n

Version связывает источник, работу и проекцию, но сама по себе не делает очередь идемпотентной. Ключ id:version — это договорённость приложения: её должен поддерживать consumer или хранилище событий. В Elasticsearch не следует автоматически принимать поле source version за служебное _version: внутренний номер документа и версия из исходной системы решают разные задачи. Для защиты от устаревших изменений подходят внешний versioning или optimistic concurrency control с if_seq_no и if_primary_term — выбор зависит от того, кто владеет порядком изменений.

\n

Учебный пример: duplicate, stale и refresh

\n

Ниже — минимальная модель без Elasticsearch, базы и сетевых задержек. Она намеренно показывает три проверяемых свойства: одинаковый ключ не создаёт вторую работу, старая версия не заменяет новую, а поиск видит принятую версию только после refresh. Код можно сохранить как файл demo.mjs и запустить командой node demo.mjs; внешние пакеты не нужны.

\n
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 как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».

\n
\"Дерево
Проверяйте путь от source к query по границам. Если текущая version уже видима, переходите к контракту запроса.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
По 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
\n

Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.

\n

Как отличить задержку видимости от ошибки запроса

\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, чтобы отличить конфликт обновления от задержки поиска.

\n

Проверяйте отрицательный путь отдельно. Для документа version 7 ожидайте пустой search до подтверждённого refresh. После refresh ожидайте ровно один hit с version 7. Для повторного ingest с ключом product-42:7 ожидайте подавление дубликата. Для старой version 6 ожидайте отказ от отката видимого документа. Эти ожидания проверяют механизм, но не обещают SLA, hit rate или поведение кластера.

\n

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

\n
  1. Запишите id, source version, точный query, индекс или alias, параметры фильтра и время наблюдения. Уберите из evidence секреты и лишние персональные данные.
  2. Проверьте source по id. Если записи нет, остановите поисковое расследование и выясните write path. Не создавайте дубликат для проверки.
  3. Проверьте ключ ingest в формате id:version. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.
  4. Найдите version в pending или видимой проекции. Если текущая version ждёт refresh, зафиксируйте время и примените только заранее выбранную policy.
  5. Если видима старая version, сравните порядок версий и найдите источник старой работы. Повторную постановку делайте идемпотентной и не стирайте исходное evidence.
  6. Если видима текущая version, проверьте query contract: поле, mapping, нормализацию текста, analyzer, filter, scope, routing и права.
  7. Повторите исходный query без дополнительных изменений. Сравните id и version в результате. Затем добавьте проверку на отрицательный путь в тест или runbook проекта.
\n

Ограничения

\n

Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.

\n

Не каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.

\n

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

\n

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

\n

Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.

\n

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

\n" }