Ночная смена: девять утра

Забрать всё, а не первую страницу

18 мин
Чему научишься
  • забирать окно источника целиком: страница за страницей, до признака конца
  • отличать курсорную от offset и понимать, что происходит, когда данные меняются во время выгрузки
  • отдавать строки генератором, чтобы окно не пришлось поднимать в память целиком
  • считать вызовы к источнику и не тратить квоту на лишний запрос

Тикет №03. «В отчёте меньше заказов, чем в снимке»

13:05. Академия открыла тикет три дня назад, но его никто не взял:

«В выгрузке за сутки 50 заказов. В самом за те же сутки 143. Расхождение стабильное: ровно 50 каждый день, сколько бы строк ни лежало в снимке».

Здесь важнее всего число «ровно 50 каждый день». В снимках количество заказов меняется, а в выгрузке раз за разом остаётся одной и той же константой.

Значит, проблема не в данных. Мы упёрлись в ЛИМИТ: пятьдесят — это размер страницы источника.

Загрузчик предшественника делает один запрос, получает первую страницу и считает работу законченной. Источник при этом честно сообщает, что данные ещё есть, — но загрузчик этот сигнал игнорирует.

Сначала воспроизведём ошибку.

В круге света на ленте стоит один ящик с одной насечкой, за границей света угадывается ещё десяток таких же; на планшете архивиста одна отметка и пустое гнездо итога.
Первая страница — не ответ источника, а его начало: пока не дошёл до конца, ты не знаешь даже, сколько их.
Один вызов порта за окно «всё, что изменилось после 12 марта 12:00». Смотри не на сами строки, а на два поля ответа: has_more и next_cursor. Вычисление полного окна внизу нужно только для сверки; разбирать его здесь не нужно. Источник прямо говорит, что отдал ещё не всё.
python · источник

Почему источник отдаёт по кускам и как забрать всё

Лимит страницы нужен не для того, чтобы усложнить жизнь клиенту. Если источник попытается вернуть слишком много строк одним ответом, ему придётся дольше держать память и соединение занятыми. А это боевая система, которая в ту же секунду обслуживает другие запросы.

Поэтому API обычно отдаёт данные страницами и вместе с каждой страницей сообщает две вещи: есть ли продолжение и с какого места читать дальше.

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

Offset. «Дай 50 строк, начиная с 100-й».

Пока источник не меняется, схема проста. Но наш приёмный порт читает заказы в порядке updated_at, а на той стороне некоторые заказы правят задним числом. У заказа прошлой недели меняется updated_at, и он прыгает в конец очереди.

Представь, что ты уже прочитал вторую страницу и переходишь к третьей. В этот момент одна строка со второй страницы уезжает в хвост. Всё, что стояло после неё, сдвигается на позицию влево. Следующий offset остаётся прежним — и одна строка проскакивает мимо выгрузки навсегда.

Симптом характерный: в витрине не хватает нескольких заказов, воспроизвести проблему трудно, а при повторной выгрузке всё внезапно «сходится».

Курсор по ключу (keyset). «Дай 50 строк ПОСЛЕ вот этой метки».

Курсор непрозрачен для клиента: его не вычисляют самостоятельно, а возвращают источнику как есть. Такой курсор кодирует не номер строки, а значения полей сортировки последней отданной строки.

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

С пагинацией разобрались. Теперь второй вопрос: куда складывать уже прочитанные страницы.

Можно собрать их в один список и вернуть целиком. Для 143 строк это незаметно, но окно суточной выгрузки может оказаться гораздо больше. Держать их все в памяти загрузчика незачем.

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

Есть и ещё одно следствие ленивого чтения: между моментом «отдал первую строку» и походом за следующей страницей может выполниться вся работа следующего шага конвейера.

И третье правило: вызовы надо считать.

Источник обычно ограничивает частоту запросов. Поэтому пустой запрос после последней страницы — не безобидная проверка «на всякий случай», а лишний расход квоты и ещё один шанс получить ответ 429 «слишком много запросов».

Если API уже сказал has_more: false, окно закончено. Дополнительно ходить за пустой страницей незачем.

КВЕРИ: Источник три дня подряд говорил «есть ещё». Его никто не слушал. Слушать источник дешевле, чем потом объяснять Академии недостачу.

Строку правят во время выгрузки: соседи сдвигаются влево, offset остаётся прежним, и одна строка проскакивает мимо навсегда. Закладка курсора хранит последний ключ, поэтому следующая строка не теряется из-за сдвига границы.

Что здесь настоящее, а что песочница. fake_api повторяет поведение пагинированного REST-источника: окно по since, ограничение limit, непрозрачный next_cursor, флаг has_more, счётчик вызовов fake_api.calls. На requests переносится сама схема обхода; сетевой вызов и разбор ответа придётся адаптировать.

Но песочница намеренно проще боевой системы. Здесь нет сети, параллельных запросов и настоящей нагрузки на источник.

Лимит частоты тоже не срабатывает сам: ответ 429 задаётся заранее, чтобы урок давал один и тот же результат у всех читателей. Поэтому счётчик вызовов в песочнице — учебная метрика. В ту же роль играют квоты источника и твой собственный лимит одновременных загрузок.

Есть ещё одна важная граница — устройство курсора. Строка c2c91dfe9 непрозрачна для тебя, но внутри шима это закодированное смещение, а не позиция в порядке.

То есть arena_source честно повторяет ПРОТОКОЛ курсорной , но не её устойчивость к сдвигу строк. Настоящий keyset-курсор хранит значения полей сортировки последней строки и не пропускает следующую строку только из-за смены её порядкового номера.

И наконец, в этом источнике вообще нет правок задним числом: updated_at ставится один раз, вскоре после created_at, и больше не меняется.

Поэтому пропуски offset-а в этом уроке ОПИСАНЫ, а не воспроизведены. Пример с offset здесь остаётся мысленным экспериментом: песочница показывает только пагинационный протокол.

Практика: напиши код
Перепиши fetch_all(since, page_size) так, чтобы порт забирал окно целиком, а не останавливался после первой страницы. Требования:
  • функция — генератор: отдаёт строки по одной (yield), а не возвращает список;
  • идёт по страницам, передавая обратно next_cursor, пока источник не скажет has_more: False;
  • since передаётся в КАЖДОМ запросе: курсор указывает место внутри окна, а окно задаёт since;
  • лишних вызовов нет. В окне ровно три страницы — значит, ровно три запроса к источнику.
В заготовке — тот самый загрузчик из тикета №03: он забирает одну страницу и складывает её в список. Проверка специально собирает результат в список, чтобы сверить строки; это механика теста, а не образец для загрузчика. Твоя задача — превратить его в ленивый обход всего окна.
python · источник
Вопрос с собеседования

Как это спрашивают на собеседовании. «Вы выгружаете таблицу через API постранично. Во время выгрузки в источнике меняются строки. Что вы получите на выходе и как с этим жить?»

Здесь проверяют не знание слова «», а понимание того, что происходит с границами страниц, пока источник меняется.

С offset-пагинацией по изменяемому порядку возможны пропуски и дубли. Если строка уезжает за уже прочитанную границу, остальные записи сдвигаются, а следующий offset остаётся прежним.

Курсор не пропустит строку только из-за сдвига номера, но появляется другая: результат всё равно не становится снимком на момент начала выгрузки. Пока ты читаешь страницы, часть строк успевает измениться и приедет уже в новой редакции.

Отсюда практический вывод, который и ждут услышать: постраничная выгрузка сама по себе не даёт согласованного среза. В нашем API since задаёт только нижнюю границу, а next_cursor — место продолжения; ни одно из этих полей не фиксирует данные на момент старта.

Частый добивающий вопрос: «а если источник отдаёт только offset?» Тогда выбирают наиболее стабильный порядок и учитывают ограничение API: один offset не может гарантировать согласованный срез.

Проверь себя
Выгрузка идёт по offset, порядок — по updated_at. Между запросами второй и третьей страниц у заказа со ВТОРОЙ страницы правят updated_at задним числом, и он уезжает в конец. Что произойдёт на границе страниц?
Проверь себя
Автор переписал fetch_all так: собирает все страницы в список rows и в конце делает return rows. Проверка урока падает на первом же ассерте. Почему это не придирка?
Главное из урока
  • Стабильные «ровно 50 заказов в сутки» из тикета №03 оказались размером страницы, а не количеством продаж. Источник сам сообщает, есть ли продолжение (has_more) и откуда его читать (next_cursor).
  • Offset ломается, когда порядок строится по изменяемому полю: строки сдвигаются, а границы страниц остаются прежними, поэтому появляются трудно воспроизводимые пропуски.
  • Курсор непрозрачен для клиента. Keyset-курсор хранит последний ключ, а не номер строки, поэтому не пропускает следующую строку из-за сдвига границы. В песочнице курса за курсором спрятано смещение: протокол настоящий, устойчивость — нет.
  • since передаётся в каждом запросе: курсор указывает место ВНУТРИ окна, а окно задаёт since.
  • Порт отдаёт генератор и сам не накапливает все строки, поэтому его память не растёт вместе с размером окна.
  • Вызовы считают. Три страницы — три запроса; четвёртый «чтобы убедиться» только зря расходует квоту и может закончиться 429.

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