Перейти к содержанию

OpenSearch: пакетный импорт и ошибки отдельных документов

Индекс library-v1 имеет явную схему, а функция documents() готовит весь корпус до обращения к серверу. Теперь передадим записи пакетами. Результат урока — импортёр, который различает успешный HTTP-ответ и успешную запись каждого документа. Это различие станет основой безопасного повторения и последующего манифеста изменений.

В архиве полный код находится в manage.py: функции bulk и sync используют транспорт из client.py. Примеры не запускались. Перед будущим выполнением настройте адрес отдельного OpenSearch, доверенный сертификат и учётные данные по README. Официальный Bulk API определяет формат запроса и структуру результата.

Две строки для одной записи

Bulk API принимает NDJSON — последовательность JSON-объектов, разделённых переводами строк. Для операции index первая строка описывает действие и идентификатор, следующая содержит документ. Это не JSON-массив, поэтому квадратные скобки и запятые между операциями не добавляются. Последняя строка также заканчивается переводом строки.

Ниже минимальный полный пакет из одного документа для уже существующего учебного индекса. Поля сокращать произвольно нельзя: в реальном импорте используется полная проекция corpus.py.

{"index":{"_id":"article-107"}}
{"doc_id":"article-107","title":"HTTP и кеширование","body":"Браузер проверяет ETag перед загрузкой.","url":"/articles/http-cache.html","section":"backend","tags":["http","cache"],"identifiers":["http","etag"],"audience":"public","published":true,"revision":1,"updated_at":"2026-10-12T09:00:00Z","content_hash":"demo-only"}

Транспорт отправляет такой пакет на /library-v1/_bulk с типом содержимого application/x-ndjson. Имя индекса уже указано в пути, поэтому его не требуется повторять в каждой строке действия. Значение demo-only иллюстрирует форму записи; полный загрузчик вычисляет настоящую контрольную сумму, а не берёт этот литерал.

Операция index создаёт документ при отсутствии _id и заменяет проекцию при его наличии. Благодаря стабильному article-107 повтор того же импорта не создаёт вторую карточку. Но это свойство не обеспечивает защиту от устаревшей редакции: более поздно отправленная старая проекция тоже может заменить новую. В нашем учебном режиме действует один последовательный импортёр.

Для операции delete нужна только строка действия. Если по привычке добавить после неё тело документа, следующая строка будет прочитана как новое действие и нарушит формат. Поэтому функция построения пакета знает тип операции, а не просто соединяет все JSON одинаковым способом. Разница особенно важна, когда обновления и удаления смешаны в одном запросе.

Ответ принадлежит каждой операции

Рассмотрим пакет из двух документов. Первый соответствует mapping, во втором дата записана некорректно. Сервер может принять первый и отклонить второй, вернув обычный успешный статус HTTP для самого Bulk-запроса. Следовательно, проверка только кода ответа недостаточна. Импортёр обязан прочитать items и сопоставить результаты с исходными действиями.

В полном manage.py результаты перебираются в том же порядке. Следующий фрагмент находится внутри bulk; group содержит текущие операции:

failures = []
for operation, item in zip(group, response.get('items', [])):
    status = next(iter(item.values())).get('status')
    acceptable = status is not None and (
        200 <= status < 300 or
        (operation[0] == 'delete' and status == 404)
    )
    if not acceptable:
        failures.append({'id': operation[1], 'status': status})
if len(response.get('items', [])) != len(group) or failures:
    raise RuntimeError('Bulk incomplete: ' + json.dumps(failures))

Проверка длины нужна потому, что zip заканчивается на более коротком списке. Без неё неполный ответ мог бы выглядеть как успех всех просмотренных операций. Отсутствующий статус тоже не принимается за успех. Мы не записываем полный ответ с содержимым документа в журнал: для редакционной диагностики достаточно идентификатора и кода отказа.

Удаление уже отсутствующего документа рассматриваем как достижение нужного состояния. Именно поэтому 404 допустим только для delete. Для чтения отсутствующей записи или отказа индексирования такой код не становится автоматически приемлемым. Логика успеха зависит от предметного результата операции, а не от желания скрыть исключение.

Пакет не является транзакцией корпуса

Если второй документ отвергнут, первая запись уже могла измениться. Повтор импортёра должен исходить из этой возможности. Нельзя откатить весь пакет простым отказом обновить локальный манифест: манифест лишь сообщает наше знание о состоянии, а не управляет уже совершёнными записями движка. Мы сохраняем старое подтверждённое состояние и допускаем повтор стабильных операций.

Например, исправив дату второго документа, снова отправим оба. Первый будет заменён идентичной проекцией, второй наконец принят. Такой повтор безопасен для конечного содержимого при одном импортёре, но расходует дополнительную работу. Для крупного корпуса можно сохранять результаты отдельных действий и повторять только отказы, внимательно различая постоянные ошибки схемы и временную перегрузку.

Ожидаемый отказ в рассматриваемом сценарии:

HTTP-запрос принят
article-107 → успешная запись
article-108 → ошибка поля даты
локальный манифест не продвинут
следующий запуск снова учитывает оба документа

Это объяснение возможного состояния, не полученный журнал. Конкретный код ошибки и текст сообщения зависят от того, как именно нарушен тип. Важно проверить структуру items, а не копировать произвольную диагностическую строку как обязательный ответ всех версий.

Размер и видимость записи

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

В запросе используется refresh=wait_for. Он связывает возвращение операции с ожиданием обновления поисковой видимости на затронутых шардах. Это удобно при изучении маленького корпуса, поскольку следующий поиск должен видеть принятую запись. Параметр не превращает пакет в единую транзакцию и не гарантирует успешность отдельных операций. При больших потоках частота ожиданий и режим refresh выбираются осознанно.

В исходниках нет бесконечного автоматического повторения любого отказа. Если mapping отвергает поле, повтор без изменения данных только воспроизводит проблему. Если соединение оборвалось после отправки, состояние может быть неопределённым, и стабильный _id помогает повторить полную запись. Так мы сохраняем различие между исправлением документа и восстановлением после неизвестного исхода.

При неопределённом сетевом исходе не следует создавать новый идентификатор документа. Если первый запрос был принят, новый случайный _id добавит вторую запись. Повтор полной проекции article-107 под прежним именем сохраняет одну предметную идентичность. Однако это предположение относится к состоянию документа, а не ко всем внутренним счётчикам и расходам движка: повторная операция может выполнить дополнительную работу.

Манифест продвигается только после принятия всех необходимых действий. Это также означает, что сообщение об успехе нельзя печатать сразу после отправки тела запроса. Сначала читается полный ответ, проверяется количество items и статус каждой операции. При обучении полезно расположить эти проверки рядом, чтобы слово «импортировано» имело одну конкретную границу.

После урока ожидаем шесть записей в library-v1 и сохранённый манифест только при полном успехе импортёра. Для упражнения составьте пакет index плюс delete и объясните число строк. Затем предположите отказ одной записи и перечислите, что известно о каждой операции. Далее создадим первый поисковый запрос, который использует подготовленные поля.

Оглавление серии