Ошибка начинается с неверного вывода: запрос на сохранение завершился успешно, значит пользователь уже увидит новую запись в текстовом поиске. Когда этот вывод не срабатывает, команда добавляет повторный запрос, принудительный 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-вызов.
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 не становится универсальным ответом на любое пустое попадание.
- Зафиксировать id, version и конкретный запрос, который должен найти документ. «Он где-то не ищется» не является проверяемым симптомом.
- Проверить source-of-truth: нужная версия действительно сохранена и доступна тому пути чтения, который заявлен контрактом.
- Проверить ingest key id:version. Повторный вызов должен быть различим как duplicate или как новая версия, а не превращаться в анонимную вторую задачу.
- Проверить, какую version принял индексатор. При несоответствии source.version и event.version не продолжать с устаревшим payload.
- Проверить, находится ли документ в промежуточном состоянии ожидания видимости. Сохранить точки времени, прежде чем менять refresh policy.
- После согласованного перехода видимости повторить тот же query и сравнить id, version, scope и фильтры. Одного найденного текста недостаточно, если запрос пользователя другой.
- Если версия стала видимой, но 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; решение о режиме зависит от нагрузки и контракта проекта