Симптом знакомый: карточка уже открывается по прямой ссылке, а поиск по её заголовку возвращает пустую выдачу или старый текст. Цена ошибки не сводится к неудобному поиску. Пользователь повторяет действие, редактор начинает создавать дубликат, а разработчик может запустить повторную индексацию, не зная, была ли исходная запись сохранена и на каком участке она перестала быть видимой. После такой спешки история изменения становится хуже исходного сбоя.
В июле 2021 года я бы начал не с параметра конкретного поискового движка, а с договора для одной выдачи. Нужно назвать момент, от которого считаем свежесть, момент, когда запись должна участвовать в запросе, и данные, по которым можно отличить обычное ожидание от потери события. Ниже используется один локальный сценарий на JavaScript. Он держит source-of-truth, ingest, pending index и видимую проекцию в памяти. Это не Elasticsearch, не база, не broker и не результат замера в production.
Свежесть выдачи — отдельный результат, а не побочный эффект записи
Сохранение источника и видимость в поиске отвечают на разные вопросы. Источник хранит корректную версию карточки. Поисковая проекция хранит форму, удобную для запроса. Между ними появляется работа: сформировать ingest-задачу, принять нужную версию, обновить индексную проекцию и открыть её для query. Если назвать все эти шаги словом «сохранили», невозможно понять, где искать причину: в записи, в постановке, в обработчике, в refresh или в самом запросе.
Договор свежести полезно писать рядом с пользовательским сценарием. Например: «после сохранения статьи обычная текстовая выдача должна получить либо видимую текущую версию, либо понятный статус ожидания; редактор не создаёт вторую статью вместо первой». Это не SLA и не числовое обещание по умолчанию. Здесь важнее граница: какую выдачу обсуждаем, какая версия источника является актуальной, какой сигнал подтверждает видимость и кто принимает решение при отставании.
| Часть договора | Что фиксируем | Чем проверяем | Чего не обещаем |
|---|---|---|---|
| Источник | id и доменная version сохранённой карточки | чтение source-of-truth по id | что query уже видит эту версию |
| Ingest | ключ id:version и состояние постановки | одна запись задачи для одинакового ключа | что любая повторная постановка создаст новую работу |
| Индексирование | version, принятая в pending-проекцию | сравнение event.version с source.version | что старая задача может переписать новую версию |
| Видимость | момент refresh и version в visible-проекции | query плюс visibleAtMs | мгновенную видимость после сохранения |
| Реакция | допустимое действие при задержке | зафиксированный маршрут диагностики | что delete и reindex всегда безопасны |
У этой таблицы есть практическая польза: она запрещает спорить о «медленном поиске» без объекта наблюдения. Если проблема относится к фильтру категории, это уже договор query. Если событие не поставлено, это ingest. Если pending-версия есть, а visible ещё нет, это переход видимости. Каждая ветка требует своей проверки и своего владельца. Нельзя лечить их одинаковым повтором сохранения страницы.
Сначала отмечаем границу источника и проекции
Для одной учебной статьи источник содержит устойчивые id, version, title и body. Его version принадлежит доменной записи, а не поисковой строке. Проекция может менять форму текста, поля и стратегию запроса, но не должна изобретать новую версию содержимого. Поэтому ingest получает id и version. Обработчик сверяет их с текущим источником до того, как положит данные в pending index. Эта проверка не делает систему распределённо согласованной; она только не даёт старому учебному событию молча выдать себя за новую карточку.
const sourceDocument = {
id: "article-2021-07-42",
version: 7,
title: "Контракт свежести выдачи",
};
// source-of-truth отвечает за содержимое и версию.
// search-индекс отвечает только за отдельную проекцию для запроса.
Стабильный ключ постановки строится из id и version. Fixture сохраняет принятый ключ отдельно от самой очереди: повтор до consume и повтор после consume получают duplicate-ingest-suppressed, а не создают вторую учебную работу. Это узкий инвариант. Он не доказывает идемпотентность реального producer, очереди или API, потому что в модели нет сети, базы и конкурирующих процессов. Но именно такой маленький тест помогает заранее назвать ключ и границу его хранения, которые в проекте придётся сохранять и наблюдать.
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.
Бюджет задержки строится из наблюдаемых точек, а не из чужого значения
Вместо формулы «поиск должен обновляться быстро» полезно записать четыре точки: savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Их разность показывает, в каком отрезке находится отставание. В fixture источник сохранён на 100, ingest поставлен на 110, проекция подготовлена на 125, а refresh сделан на 160. Разница между сохранением и видимостью равна 60 условным миллисекундам. Это арифметика теста, не реальная задержка и не целевой бюджет для какого-либо кластера.
Когда эти точки известны, команда может договориться о реакции без ложной точности. Для обычной выдачи допустимо показать состояние ожидания и измерить следующую проверку. Для сценария, который обязан читать свою запись, потребуется другой путь: например, чтение источника или явно выбранный режим ожидания. Выбирать его надо по цене ожидания, нагрузке и поведению конкретной версии движка. Историческая документация Elasticsearch 7.13 описывает refresh как переход, делающий операции доступными search, и отдельно предупреждает, что search работает near-real-time. Это описание механизма, а не переносимый SLA.
const freshnessContract = {
object: "карточка статьи",
searchAudience: "обычная выдача",
startsAt: "source-saved",
endsAt: "visible-to-query",
evidence: ["sourceVersion", "eventKey", "savedAtMs", "indexedAtMs", "visibleAtMs"],
policy: "проект выбирает ожидание, уведомление или controlled refresh",
};
// Это структура разговора о свежести, а не значение SLA.
В реальном проекте сама единица бюджета тоже зависит от вопроса. Иногда нужно измерить время от публикации до первой видимости. Иногда важнее число текущих pending-версий или доля запросов, где карточка не прошла фильтр. Нельзя смешивать их в один «лаг поиска». Один показатель отвечает на вопрос о pipeline, другой — о запросе, третий — о содержимом. Сначала выбираем один пользовательский случай, затем оставляем у него минимальный набор времени, version и ключа события.
Fixture показывает stale выдачу без настоящего движка
Сквозная fixture сначала сохраняет документ в source-of-truth и ставит ingest. Затем второй вызов постановки с тем же id и version подавляется. После обработки document лежит в pending index, но searchVisible ещё возвращает ноль попаданий. Это и есть контролируемая stale выдача: источник существует, проекция подготовлена, но переход видимости не выполнен. Только после явного refreshSearch тот же запрос получает одну карточку с version 7.
Такой пример важен именно своей скромностью. Он не строит индекс, не анализирует русский текст так же, как поисковый движок, не вызывает HTTP и не имитирует shard. Его результат проверяем: assertions фиксируют сохранение источника, подавление дубликата, пустой поиск до refresh, видимый текущий version после refresh, арифметику 60 и неразрушающий диагноз. Если один из этих результатов перестать выполняться после правки модели, Node завершится ошибкой.
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
Маршрут для обычной выдачи
Маршрут остаётся линейным: симптом — source уже сохранён, а query пуст; причина ищется в одном из переходов id:version; проверка фиксирует version и точки времени; действие выбирается только для найденной границы. Такой порядок не подменяет отставание видимости повторным сохранением карточки.
- Выбрать одну выдачу и один объект: например, поиск опубликованной статьи по заголовку. Не начинать с общего слова «индекс».
- Записать source id и version, затем определить, какой ключ представляет постановку для этой версии.
- Сохранить точки savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs там, где они реально доступны. Не подставлять значения из fixture в рабочие логи.
- Проверить один отрицательный путь: source уже существует, а query до refresh не находит документ. Это отличает ожидание видимости от потери source.
- Для одинакового id:version повторить постановку и убедиться, что наблюдаемое действие не создаёт второй независимый ingest.
- Выбрать реакцию для превышения проектного бюджета: ждать следующего планового перехода, показывать статус или запускать заранее согласованную проверку. Не начинать с удаления источника.
- После исправления снова проверить query, version и время видимости. Если запрос всё ещё пустой, перейти к фильтру, области поиска и анализу текста, а не повторять refresh бесконечно.
Что остаётся за границей этой заметки
Elasticsearch 7.13 документирует, что refresh управляет видимостью для search, а Index API имеет собственные параметры refresh и versioning. Эти сведения нужны, чтобы не считать успешный index request универсальным признаком видимости. Но учебная Map не является проекцией внутреннего устройства Elastic и не может подтвердить поведение любого индекса, alias, реплики или настройки interval. Значения 100, 110, 125 и 160 выбраны только для детерминированного теста.
В пакете не запускаются Elasticsearch, база, брокер, HTTP, браузер, CI и deployment. Здесь нет настоящего SLA, лога нагрузки, данных пользователей или обещания мгновенного поиска. Следующий проверяемый шаг в своём проекте — взять один безопасный документ, записать его id и version, сравнить момент сохранения с моментом поиска и отдельно проверить политику refresh выбранной исторической версии движка. Тогда симптом станет маршрутом, а не поводом создавать дубликаты.
Проверяемые источники
- 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: The refresh parameter — историческое описание значений false, true и wait_for и их влияния на видимость операции для search; решение о режиме зависит от нагрузки и контракта проекта