Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Пагінація: keyset-курсор і дешевий count

Як фіди creatives, tasks, коментарів і історії гортаються двофазним keyset-курсором і чому total рахується лише на першій сторінці.

Списки creatives і tasks, а також фіди задачі — коментарі й історія статусів — гортаються range-based (keyset) курсором, не skip/limit. Реалізація — kumquat_core/pagination.py (кодування курсора й предикати фаз) плюс BaseMongoRepository.keyset_page у kumquat_core/db/repository.py; назовні це meta.next_cursor у відповідях GET /creatives, GET /creatives/gallery, GET /tasks, GET /tasks/{id}/comments і GET /tasks/{id}/history.

Чому не skip/limit

skip(N) не «перестрибує» N документів — Mongo сканує їх по індексу і викидає, тож сторінка 200 коштує у 200 разів дорожче за першу. Друга проблема — стабільність: вставки й видалення між запитами зсувають вікно, і рядки дублюються або губляться між сторінками. Keyset читає наступну сторінку як діапазон по індексу, тож вартість сторінки стала, а потік стабільний під паралельні записи.

Дві фази: значення, потім null-и

Сортувальні поля бувають порожні (metrics.*, media.*), тому скан розбитий на дві фази:

  1. PHASE_VALUES — документи, у яких поле має значення; порядок (value, _id).
  2. PHASE_NULLS — хвіст без значення; порядок тільки по _id.

Type bracketing у Mongo змушує $lt/$gt пропускати null-и автоматично, тож обидві фази лишаються звичайними find. Коли фаза 0 вичерпалась рівно на межі сторінки, keyset_page провалюється у хвіст на тій самій сторінці й видає курсор фази 1 з i: None.

Обидва предикати виписують $exists явно, хоча {field: None} у MongoDB і так матчить і null, і відсутнє поле. Причина — тестовий двійник: неоцінений креатив зберігає metrics: null, тож дот-шлях metrics.spend резолвиться через null-батька, і mongomock_motor кладе такий документ або в обидві фази, або в жодну. Явна форма еквівалентна для MongoDB і тримає фази неперетинними під обома рушіями.

Курсор

Непрозорий urlsafe-base64 токен, у якому лежать (field, direction, phase, last value, last _id), серіалізовані через bson.json_util — щоб datetime і ObjectId пережили round-trip без втрати типу.

Сортування вшите в токен навмисно: курсор не можна продовжити під іншим порядком. decode_cursor кидає InvalidCursor і на зіпсований токен, і на токен від іншого сорту; роути перетворюють це на 422.

Індекси

Кожне дозволене сортування потребує compound-індексу (field, _id) — див. CREATIVE_KEYSET_INDEXES у kumquat_creatives/indexes.py. Фіди задачі мають по одному такому: (entity_type, entity_id, created_at, _id) для коментарів і (entity_type, entity_id, action, at, _id) для історії — там action стоїть перед ключем сорту, бо це рівність (task.status_changed), а не діапазон. Однополевий індекс не задовольняє жодну з двох фаз, і Mongo відповідає на такий запит блокуючим top-K сортом по всьому збігу — тобто offset-пагінацією, перевдягненою в курсор. Приємний побічний ефект: (field, 1), (_id, 1) є надмножиною простого індексу по field — той самий префікс.

Дешевий count

total віддається тільки на першій сторінці (без курсора) — далі фронт його вже має, тож count не виконується на кожен «Load more».

Сам count теж не завжди сканує: для нефільтрованого фіду (лендінг) береться estimated_document_count(), який читає метадані колекції за O(1). З фільтром — звичайний count_documents(query).

  • estimated_document_count() не приймає фільтр і може брехати одразу після unclean shutdown вузла. Для «всього creatives: ~12 400» в UI це прийнятно.
  • Кешування count у Redis не реалізоване. Якщо знадобиться — врахувати пастку нашого query-builder: оператор last_days_interval підставляє datetime.now(UTC) прямо в query, тож hash щоразу новий і кеш ніколи не спрацює. Нормалізувати поріг до хвилини перед хешуванням, або кешувати по сирому JSON-правилу з фронта, а не по згенерованому query.

Наслідок для UI

«Стрибнути на сторінку 17» неможливо — тільки вперед. Це лягає на infinite scroll / «Load more», що фронт і робить: гортає meta.next_cursor, доки той не стане null.

Was this page helpful?