При постраничном чтении списка между запросами появляются новые записи. Какой контракт пагинации должен проверить интеграционный тест, чтобы клиент не пропускал элементы и не получал дубли?
Интеграционный тест должен проверять пагинацию по стабильному курсору с однозначным порядком сортировки, а не по изменяемому смещению. Курсор должен однозначно продолжать чтение после последнего элемента предыдущей страницы, включая уникальный критерий разрешения одинаковых значений сортировки.
Если API обещает единый снимок набора данных, контракт должен дополнительно фиксировать этот снимок на весь процесс чтения. Без явно заданной семантики невозможно одновременно гарантировать полный проход по постоянно изменяющемуся набору и включение всех новых записей.
Offset-пагинация появилась как простой способ разделить большой результат на страницы: клиент передаёт номер страницы или количество пропускаемых записей. Такой подход удобен для небольших и относительно неизменных наборов данных, но плохо работает при вставках и удалениях между запросами.
Курсорная пагинация решает эту проблему, связывая следующую страницу не с её номером, а с позицией последнего обработанного элемента. Это уменьшает зависимость результата от изменений в начале набора и обычно лучше подходит для API с большими или часто изменяющимися коллекциями.
Предположим, первая страница содержит записи, отсортированные по времени создания. После её получения в начало списка добавляется новая запись. Если второй запрос использует смещение, прежние записи сдвигаются, поэтому одна из них может повториться, а другая — быть пропущена.
Проблема возникает не только при вставках. Удаление элементов, изменение поля сортировки и одинаковые значения этого поля также могут нарушить последовательность. Тест, проверяющий только размер страницы и наличие ссылки на следующую страницу, такие дефекты не выявит.
Контракт должен описывать несколько свойств.
Стабильный полный порядок. Сортировка должна использовать поле, значение которого не меняется во время обхода, например время создания, дополненное уникальным идентификатором. Одного времени недостаточно: у нескольких записей может быть одинаковая временная метка.
Курсор как позиция, а не номер страницы. Следующий курсор должен кодировать последний элемент или эквивалентную позицию в упорядоченном наборе. Его внутренний формат лучше считать непрозрачным для клиента.
Определённая семантика границы. Если последняя запись первой страницы имеет ключ сортировки (время, идентификатор), следующая страница должна начинаться строго после этой пары. Иначе граничный элемент может попасть в обе страницы.
Зафиксированная консистентность. Возможны разные контракты: чтение актуального набора на каждом запросе или чтение логического снимка, созданного первым запросом. Для защиты от пропусков при изменениях обычно выбирают курсор со стабильным порядком; для полного согласованного отчёта нужен снимок или версия набора.
Интеграционный тест должен создать записи с одинаковыми значениями сортировки, получить первую страницу, затем вставить и удалить элементы до и после её границы. После последовательного получения страниц тест проверяет отсутствие дублей, отсутствие пропусков в пределах обещанной семантики и монотонное продвижение курсора.
Курсор не устраняет все ограничения. Если запись после выдачи первой страницы изменит поле сортировки, она может переместиться относительно уже прочитанных данных. Поэтому сортировать следует по неизменяемым полям либо явно документировать поведение при изменении записи.
Offset-пагинация проще для перехода на произвольную страницу и подсчёта общего количества элементов, но нестабильна при изменениях и может быть дорогой на больших смещениях. Курсорная пагинация устойчивее для последовательного чтения, но не позволяет надёжно перейти на произвольную страницу, а курсор нужно корректно защищать от подделки и устаревания.
Сервис выгружал журнал операций по десять элементов через offset. Интеграционный тест проходил на статичных данных, но в рабочей среде параллельные операции постоянно добавляли новые записи. В результате отчёт содержал повторы, а часть операций не попадала в выгрузку.
Рассматривались три варианта. Увеличение размера страницы уменьшало вероятность ошибки, но не устраняло её. Блокировка новых записей на время выгрузки давала согласованный результат, однако ухудшала доступность и масштабирование. Курсор по неизменяемой паре «время создания плюс идентификатор» не блокировал записи и обеспечивал стабильное последовательное продвижение.
Выбрали третий вариант и явно зафиксировали в контракте, что выгрузка обрабатывает записи, существовавшие до начала обхода, а новые записи попадут в следующий запуск. Тест стал моделировать вставки между запросами и проверять уникальность и полноту результата относительно этого правила. Дефект со смещением был воспроизведён до выката изменения.
1. Достаточно ли сортировки только по времени создания?
Нет. Время создания может совпадать у нескольких записей, особенно при высокой нагрузке или ограниченной точности хранения. Без уникального дополнительного ключа порядок таких записей не определён, поэтому курсор может пропустить одну из них или вернуть её повторно.
Контракт должен задавать детерминированный порядок, например сортировку по (created_at, id). Тесту следует создавать несколько записей с одинаковым первым ключом и проверять, что граница между страницами проходит однозначно.
2. Гарантирует ли курсор абсолютную полноту при удалении записей?
Нет, такая гарантия зависит от семантики API. Курсор предотвращает типичные сдвиги, но не создаёт снимок данных и не может вернуть запись, удалённую до момента её чтения.
Если требуется воспроизводимая выгрузка, сервер должен предоставить механизм снимка: например, версию набора или временную границу, связанную с курсором. Интеграционный тест должен проверять именно заявленный контракт, а не требовать невозможной полноты для изменяющегося набора.
3. Можно ли раскрывать клиенту внутренний идентификатор последней записи вместо курсора?
Иногда это технически возможно, но такой контракт жёстко связывает клиента с моделью хранения и правилами сортировки. При смене структуры ключа или способа распределения данных совместимость может нарушиться.
Непрозрачный курсор позволяет серверу менять внутреннее представление позиции. При этом сервер должен валидировать курсор, обрабатывать его устаревание и возвращать согласованную ошибку, если курсор больше нельзя применить. Тест должен проверять не конкретное содержимое курсора, а его поведение: продолжение чтения, отклонение повреждённого значения и отсутствие повторов на границе.