Пагінація: 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.*), тому скан розбитий на дві фази:
PHASE_VALUES— документи, у яких поле має значення; порядок(value, _id).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.