DarkRiDDeR17 мин

Индексация и поиск: почему запись не означает видимость

ПоискДанныеМеханизм

Ошибка начинается с неверного вывода: запрос на сохранение завершился успешно, значит пользователь уже увидит новую запись в текстовом поиске. Когда этот вывод не срабатывает, команда добавляет повторный запрос, принудительный refresh или второй кеш, не зная, на каком переходе пропала ожидаемая версия. Цена — не только лишняя нагрузка. Появляются два объяснения одной карточки: source считает актуальной version 7, а выдача показывает version 6 или ничего.

Разложим механизм на четыре простых состояния. Source-of-truth владеет содержимым и version. Ingest хранит намерение построить проекцию. Pending index содержит уже подготовленный документ, который ещё не участвует в учебном query. Visible index — снимок, из которого query возвращает попадания. Все четыре состояния в этой статье — Map внутри Node. Они специально не являются ни базой, ни поисковым движком, ни broker, ни договором доставки. Их задача — сделать спор о видимости проверяемым.

У одного документа несколько моментов готовности

Документ может быть сохранён и при этом не готов для всех читателей. Для карточки по прямой ссылке достаточно источника. Для фонового обработчика может быть достаточно id и version в ingest. Для полнотекстового поиска нужна готовая видимая проекция. Эти результаты нельзя свести к одному булеву saved, потому что у них разные владельцы и разные доказательства. Если интерфейс обещает «опубликовано», а поиск живёт отдельным переходом, это нужно назвать прямо в контракте.

Состояния учебной проекции и их границы
СостояниеВладелец в моделиЧто уже доказаноЧего ещё нет
source-of-truthдоменная записьid, version и текст сохраненынет признака, что search видел документ
ingest queueпостановка проекцииесть одна задача id:versionнет признака, что проекция применена
pending indexиндексаторактуальная версия подготовлена для переходаquery ещё возвращает старый visible snapshot
visible indexконтур поискаquery может вернуть version в данном учебном снимкенет доказательства о другом фильтре, alias или среде

Эта схема не означает, что в каждом проекте нужны четыре отдельные технологии. Иногда source и ingestion живут в одном приложении, иногда данные приходят из другого сервиса. Важно другое: у каждого перехода есть наблюдаемое условие. Если source.version равна 7, а event.version равна 6, обработчик не должен выдавать старую задачу за актуальную. Если pending.version равна 7, а query пустой, надо проверять видимость, а не содержимое источника.

Ingest должен передавать не только id, но и версию

Один id показывает, о какой карточке идёт речь, но не отвечает на вопрос о порядке обновлений. Поэтому учебное событие содержит id и version. Ключ id:version даёт простую границу повтору: две одинаковые постановки становятся одним наблюдаемым намерением. Это не защита от всех гонок. Например, в модели нет параллельного producer и нет durable queue. Но если код не может объяснить, что будет при втором вызове с тем же ключом, он уже не готов к реальному переходу между компонентами.

function enqueueIngest(queue, seenKeys, document) {
  const key = document.id + ":" + document.version;
  if (seenKeys.has(key)) return { state: "duplicate-ingest-suppressed", key };
  queue.set(key, { id: document.id, version: document.version });
  seenKeys.add(key);
  return { state: "ingest-enqueued", key };
}

// seenKeys задаёт учебную границу идемпотентности после consume.

После извлечения события индексатор читает текущий source и сравнивает version. Если источник уже изменился, старое событие получает состояние stale-ingest-skipped и не кладёт устаревший текст в pending index. У этой ветки одна цель: не перепутать текущую доменную запись с задержавшейся работой. Она не заменяет optimistic concurrency control или внешнее versioning настоящего движка. В Elasticsearch 7.13 Index API действительно документирует собственную модель versioning; переносить её параметры в этот пример было бы ложным сходством.

function applyCurrentSourceVersion(source, event, pendingIndex) {
  if (source.version !== event.version) {
    return { state: "stale-ingest-skipped", sourceVersion: source.version };
  }
  pendingIndex.set(source.id, { ...source, indexedAtMs: 125 });
  return { state: "indexed-pending-refresh", version: source.version };
}

// Проверяем актуальную версию до изменения проекции.

Refresh меняет видимость, а не историю источника

После успешного индексирования fixture не копирует документ сразу в visible index. Она оставляет его в pending index и запускает тот же query. Результат нулевой, хотя source существует и event был обработан. Это намеренное место stale выдачи. Оно показывает, почему фраза «документ в индексе» может быть недостаточной: нужно уточнить, в каком именно состоянии и для какого вида чтения.

Затем refreshSearch переносит pending-документ в видимую проекцию. В учебной модели этот вызов явный, синхронный и не имеет стоимости. В реальном Elastic refresh — понятие конкретного движка и конкретной версии: документация 7.13 описывает его как механизм, который делает операции с индексом доступными search, а параметры Index API отдельно различают отсутствие действий, ожидание refresh и его запрос. Из этого не следует, что explicit refresh нужно ставить в каждый write-path или что он даёт одинаковую цену на любой нагрузке.

function refreshVisibleIndex(pendingIndex, visibleIndex, atMs) {
  for (const document of pendingIndex.values()) {
    visibleIndex.set(document.id, { ...document, visibleAtMs: atMs });
  }
  pendingIndex.clear();
}

// В fixture refresh — явный переход состояния, не HTTP-вызов.
Схема учебного бюджета задержки: источник сохранён в точке 100, ingest поставлен в 110, pending index подготовлен в 125, refresh завершён в 160, query до refresh видит ноль попаданий, после него — текущую version; подпись отмечает, что 60 условных миллисекунд не являются SLA
Временная шкала fixture: наблюдаемая задержка складывается из переходов, а не называется «лагом поиска» без доказательств.

Search и прямое чтение отвечают на разные вопросы

Иногда расследование осложняет то, что один способ чтения уже видит обновление, а другой ещё нет. В документации Elasticsearch 7.13 Get API по умолчанию обозначен как realtime и не зависит от момента, когда данные становятся видимыми search. Это полезная историческая граница: успешное чтение по id и успешный текстовый query могут требовать разного доказательства. Но нельзя переносить этот факт в любую архитектуру. В fixture source Map не эмулирует Get API, а visible Map не эмулирует индекс Elastic; модель лишь делает различие явным.

Практический вывод короткий: в отчёте о сбое нужно указать, каким именно чтением найден документ. «Карточка открылась» и «поиск вернул карточку с нужным фильтром» — два разных факта. Первый проверяет source path. Второй проверяет query path, который включает видимость, область поиска, поля, анализ текста и фильтры. Когда эти факты склеены, повторная индексация маскирует проблему вместо того, чтобы найти её участок.

Fixture проверяет переходы, а не рисует счастливую схему

В runSearchIndexingFixture() один документ с version 7 сначала сохраняется в source. Первая постановка создаёт ingest-задачу, вторая с тем же ключом подавляется. До index и до refresh поиск по слову «свежести» пуст. После обработки pending index содержит документ, а безопасный диагноз говорит awaiting-refresh и запрещает destructive action. После refresh query возвращает ровно один hit с version 7; диагноз перемещается в query-contract.

const fixture = runSearchIndexingFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
  throw new Error("search fixture failed");
}

fixture.beforeRefreshSearch.hitCount; // 0: source уже сохранён, search ещё stale
fixture.afterRefreshSearch.hitCount;  // 1: тот же version стал видимым
fixture.observedLagMs;                // 60: учебная арифметика, не SLA

Assertions не проверяют скорость Elastic, устойчивость storage или поведение production. Они проверяют только то, что обещает сама модель: порядок этапов, один ingest-key, отсутствие попадания до refresh, одно попадание после него, сохранение version и арифметику 60 условных миллисекунд. Если добавить вторую версию, alias или анализатор, нужно сначала расширить fixture и назвать новый инвариант. Нельзя выдать текущий маленький прогон за тест распределённой поисковой системы.

Маршрут от записи к видимому query

Симптом здесь один: search не подтверждает ожидаемую version. Причина может быть только в source, ingest, pending-проекции, переходе видимости или самом query. Проверка идёт в этом порядке, а действие относится к найденной границе. Так forced refresh не становится универсальным ответом на любое пустое попадание.

  1. Зафиксировать id, version и конкретный запрос, который должен найти документ. «Он где-то не ищется» не является проверяемым симптомом.
  2. Проверить source-of-truth: нужная версия действительно сохранена и доступна тому пути чтения, который заявлен контрактом.
  3. Проверить ingest key id:version. Повторный вызов должен быть различим как duplicate или как новая версия, а не превращаться в анонимную вторую задачу.
  4. Проверить, какую version принял индексатор. При несоответствии source.version и event.version не продолжать с устаревшим payload.
  5. Проверить, находится ли документ в промежуточном состоянии ожидания видимости. Сохранить точки времени, прежде чем менять refresh policy.
  6. После согласованного перехода видимости повторить тот же query и сравнить id, version, scope и фильтры. Одного найденного текста недостаточно, если запрос пользователя другой.
  7. Если версия стала видимой, но query пуст, перейти к контракту запроса. Если нет — исправлять участок ingest или refresh с обратимым планом, а не удалять источник.

Ограничения и историческая рамка

Эта модель намеренно не хранит documents на диске, не создаёт сегменты, не выбирает refresh interval и не знает ничего о репликах. Она не обещает near-real-time как точную задержку. Значения времени — только входные данные для assertion. Исторические источники Elastic 7.13 нужны, чтобы правильно разделить index request, refresh и search visibility, но фактическое поведение проекта зависит от версии, настройки индекса, нагрузки, прав, routing и способа запроса.

Следующий шаг — не включить опцию по чужой рекомендации, а проверить один поток собственной системы. Нужны id, version, timestamp и один фиксированный query. Затем можно решить, где хранить evidence, кто владеет свежестью и какая реакция допустима при отставании. Такая последовательность оставляет границу между источником и поиском понятной и не заставляет авторизованный write-path отвечать за всё поведение выдачи.

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

  • Elasticsearch 7.13: Near real-time search — историческая документация линии 7.13: refresh делает операции с индексом доступными для поиска; описание near-real-time не является SLA учебной модели
  • Elasticsearch 7.13: Index API — источник для параметра refresh и раздела versioning; fixture не вызывает API и не задаёт настройки движка
  • Elasticsearch 7.13: Get API — документация различает realtime GET по умолчанию и момент, когда данные видны search; это граница для диагностики, а не модель хранилища в статье
  • Elasticsearch 7.13: The refresh parameter — историческое описание значений false, true и wait_for и их влияния на видимость операции для search; решение о режиме зависит от нагрузки и контракта проекта