Забрать всё, а не первую страницу
Чему научишься
- забирать окно источника целиком: страница за страницей, до признака конца
- отличать курсорную от offset и понимать, что происходит, когда данные меняются во время выгрузки
- отдавать строки генератором, чтобы окно не пришлось поднимать в память целиком
- считать вызовы к источнику и не тратить квоту на лишний запрос
Тикет №03. «В отчёте меньше заказов, чем в снимке»
13:05. Академия открыла тикет три дня назад, но его никто не взял:
«В выгрузке за сутки 50 заказов. В самом за те же сутки 143. Расхождение стабильное: ровно 50 каждый день, сколько бы строк ни лежало в снимке».
Здесь важнее всего число «ровно 50 каждый день». В снимках количество заказов меняется, а в выгрузке раз за разом остаётся одной и той же константой.
Значит, проблема не в данных. Мы упёрлись в ЛИМИТ: пятьдесят — это размер страницы источника.
Загрузчик предшественника делает один запрос, получает первую страницу и считает работу законченной. Источник при этом честно сообщает, что данные ещё есть, — но загрузчик этот сигнал игнорирует.
Сначала воспроизведём ошибку.

has_more и next_cursor. Вычисление полного окна внизу нужно только для сверки; разбирать его здесь не нужно. Источник прямо говорит, что отдал ещё не всё.Почему источник отдаёт по кускам и как забрать всё
Лимит страницы нужен не для того, чтобы усложнить жизнь клиенту. Если источник попытается вернуть слишком много строк одним ответом, ему придётся дольше держать память и соединение занятыми. А это боевая система, которая в ту же секунду обслуживает другие запросы.
Поэтому API обычно отдаёт данные страницами и вместе с каждой страницей сообщает две вещи: есть ли продолжение и с какого места читать дальше.
Продолжать чтение можно двумя способами. Разница между ними становится критичной, когда данные меняются прямо во время выгрузки.
Offset. «Дай 50 строк, начиная с 100-й».
Пока источник не меняется, схема проста. Но наш приёмный порт читает заказы в порядке updated_at, а на той стороне некоторые заказы правят задним числом. У заказа прошлой недели меняется updated_at, и он прыгает в конец очереди.
Представь, что ты уже прочитал вторую страницу и переходишь к третьей. В этот момент одна строка со второй страницы уезжает в хвост. Всё, что стояло после неё, сдвигается на позицию влево. Следующий offset остаётся прежним — и одна строка проскакивает мимо выгрузки навсегда.
Симптом характерный: в витрине не хватает нескольких заказов, воспроизвести проблему трудно, а при повторной выгрузке всё внезапно «сходится».
Курсор по ключу (keyset). «Дай 50 строк ПОСЛЕ вот этой метки».
Курсор непрозрачен для клиента: его не вычисляют самостоятельно, а возвращают источнику как есть. Такой курсор кодирует не номер строки, а значения полей сортировки последней отданной строки.
Поэтому сдвиг соседних строк не заставляет пропустить следующую строку на границе страниц. Но снимком на начало выгрузки результат от этого не становится: изменившаяся строка может появиться снова. Непрозрачность здесь важна: клиент не знает устройство курсора, не пытается его пересчитать и не рассинхронизируется с источником.
С пагинацией разобрались. Теперь второй вопрос: куда складывать уже прочитанные страницы.
Можно собрать их в один список и вернуть целиком. Для 143 строк это незаметно, но окно суточной выгрузки может оказаться гораздо больше. Держать их все в памяти загрузчика незачем.
Поэтому порт отдаёт генератор. Приехала страница — строки ушли дальше по ленте — страницу можно забыть. Здесь генератор важен не как стилистический приём, а как способ не накапливать всё окно в памяти.
Есть и ещё одно следствие ленивого чтения: между моментом «отдал первую строку» и походом за следующей страницей может выполниться вся работа следующего шага конвейера.
И третье правило: вызовы надо считать.
Источник обычно ограничивает частоту запросов. Поэтому пустой запрос после последней страницы — не безобидная проверка «на всякий случай», а лишний расход квоты и ещё один шанс получить ответ 429 «слишком много запросов».
Если API уже сказал has_more: false, окно закончено. Дополнительно ходить за пустой страницей незачем.
КВЕРИ: Источник три дня подряд говорил «есть ещё». Его никто не слушал. Слушать источник дешевле, чем потом объяснять Академии недостачу.
Что здесь настоящее, а что песочница. 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;- лишних вызовов нет. В окне ровно три страницы — значит, ровно три запроса к источнику.
Вопрос с собеседования
Как это спрашивают на собеседовании. «Вы выгружаете таблицу через API постранично. Во время выгрузки в источнике меняются строки. Что вы получите на выходе и как с этим жить?»
Здесь проверяют не знание слова «», а понимание того, что происходит с границами страниц, пока источник меняется.
С offset-пагинацией по изменяемому порядку возможны пропуски и дубли. Если строка уезжает за уже прочитанную границу, остальные записи сдвигаются, а следующий offset остаётся прежним.
Курсор не пропустит строку только из-за сдвига номера, но появляется другая: результат всё равно не становится снимком на момент начала выгрузки. Пока ты читаешь страницы, часть строк успевает измениться и приедет уже в новой редакции.
Отсюда практический вывод, который и ждут услышать: постраничная выгрузка сама по себе не даёт согласованного среза. В нашем API since задаёт только нижнюю границу, а next_cursor — место продолжения; ни одно из этих полей не фиксирует данные на момент старта.
Частый добивающий вопрос: «а если источник отдаёт только offset?» Тогда выбирают наиболее стабильный порядок и учитывают ограничение API: один offset не может гарантировать согласованный срез.
updated_at. Между запросами второй и третьей страниц у заказа со ВТОРОЙ страницы правят updated_at задним числом, и он уезжает в конец. Что произойдёт на границе страниц?fetch_all так: собирает все страницы в список rows и в конце делает return rows. Проверка урока падает на первом же ассерте. Почему это не придирка?Главное из урока
- Стабильные «ровно 50 заказов в сутки» из тикета №03 оказались размером страницы, а не количеством продаж. Источник сам сообщает, есть ли продолжение (
has_more) и откуда его читать (next_cursor). - Offset ломается, когда порядок строится по изменяемому полю: строки сдвигаются, а границы страниц остаются прежними, поэтому появляются трудно воспроизводимые пропуски.
- Курсор непрозрачен для клиента. Keyset-курсор хранит последний ключ, а не номер строки, поэтому не пропускает следующую строку из-за сдвига границы. В песочнице курса за курсором спрятано смещение: протокол настоящий, устойчивость — нет.
sinceпередаётся в каждом запросе: курсор указывает место ВНУТРИ окна, а окно задаётsince.- Порт отдаёт генератор и сам не накапливает все строки, поэтому его память не растёт вместе с размером окна.
- Вызовы считают. Три страницы — три запроса; четвёртый «чтобы убедиться» только зря расходует квоту и может закончиться 429.
Дальше по ленте: порт уже умеет забрать окно целиком и разобрать строку. Теперь осталось проверить, что случится, если запустить его дважды за одну и ту же дату.