← Cache & Workers

Тесты

cache-workers · workzone
Принятые решения
Тестируем конвенции слоя, не библиотеки Redis и SAQ как библиотеки доверены их авторам — здесь проверяются наши правила поверх них: единственность задачи, очистка протухших прогонов, ретраи и backoff, политика кэша, изоляция лейнов. Не дублируем тесты брокера и драйвера.
Единственность и очистку — на реальной БД и поддельном времени Замок одного активного прогона держит партиал-UNIQUE индекс Postgres, а не проверка в коде — значит, тест поднимает БД и бьёт в индекс. Протухание heartbeat_at и окна TTL/cron гоним через подменённые часы (time-machine), не реальным ожиданием.
Rate-limit Lua — под конкурентной нагрузкой Атомарность token-bucket / sliding-window видна только при гонке: тест шлёт параллельные запросы и проверяет, что сверх лимита не просочилось ни одного — последовательная проверка такой баг не ловит.
Стек и инфраструктура
Имеется done pytest, pytest-asyncio
Redis fakeredis для unit-логики ключей; поднятый Redis для атомарности Lua и переживания рестарта
Время time-machine — TTL кэша, протухание heartbeat_at, cron-окна и пересчёт следующего тика
Воркер SAQ-обработчик вызовом напрямую — без поднятия воркера и реального опроса очереди
Маркеры @pytest.mark.unit / @pytest.mark.integration — по типу; p0 / p1 — по приоритету, ортогонально типу
Unit · P0 Идемпотентность постановки детерминированно · каждый PR
test_enqueue_idempotency.py
Повтор того же job_id отбрасывается, окно после терминала
Кейсы
дубль в очередиповторный enqueue того же job_id, пока задача ещё в очереди или выполняется, отбрасывается — второй задачи не появляется
окно после успехаjob_id завершённой succeeded-задачи держится ~24ч (time-machine) — повтор в окне отбрасывается, после окна проходит
окно после провалапосле failed окно короткое — задачу можно переставить раньше, чем после успеха
Integration · P0 Единственность и очистка stale БД · отдельный шаг CI
test_uniqueness_reaping.py
Второй активный прогон отвергнут индексом, протухший heartbeat очищается
Кейсы
второй активный отвергнутпри ещё активном прогоне попытка второго отбивается партиал-UNIQUE индексом — гонку гасит сама база, не только проверка в коде
протухший heartbeat → staleворкер умер, не закрыв прогон → по протуханию heartbeat_at (time-machine) прогон очищается в failed/stale (очистка)
замок отпущен после очисткипосле очистки stale-прогона замок снимается, следующий прогон стартует — зомби не запирает задачу навсегда
реапинг стоит с singleton, разгребает на возвратесторож живёт на singleton — пока тот лежит, реапинг тоже стоит и протухшие прогоны копятся; на возврате несколько накопившихся протухших очищаются одним проходом, а не по одному за тик (сторож на singleton)
мягкий слив по SIGTERMштатная остановка (не крах): воркер доводит начатое, а недоведённая задача возвращается в очередь и подхватывается заново — повтор безопасен по идемпотентности job_id, не через реапинг (мягкий слив)
Unit · P0 Ретраи и backoff логика детерминированна · каждый PR
test_retry_backoff.py
Короткий vs долгий ретрай, классификация, exp-backoff + jitter
Кейсы
короткий ретрай in-memoryзадержка ≤60с — повтор внутри того же слота, без возврата в очередь
долгий — defer/re-enqueueзадержка больше порога → задача откладывается и переставляется в очередь, слот воркера освобождается на время ожидания
transient vs permanentвременный сбой классифицируется как transient и ретраится; постоянный (permanent) уходит в терминал сразу, без попыток
exp-backoff + full jitterзадержка растёт экспоненциально с полным джиттером — две серии попыток не совпадают по таймингу
cap и потолок попытокзадержка упирается в верхний cap; на исчерпании числа попыток задача уходит в терминал, не ретраится бесконечно
Integration · P1 Терминал и DLQ БД
test_terminal_dlq.py
Итог в строке потребителя, падение по политике (не в Redis)
Кейсы
итог не теряетсярезультат прогона записан в Postgres-строку модуля-потребителя — durable, переживает завершение задачи в брокере
падение по политикеисчерпавшее попытки падение пишется по политике потребителя — DLQ-таблица / статус «не доставлено» / failed-items — а не в durable Redis-очередь
очередь не копит мёртвоеRedis-очередь не накапливает терминальные задачи — после терминала запись о ней живёт в Postgres, не в брокере
Integration · P1 Rate-limit под нагрузкой поднятый Redis · конкурентно
test_rate_limit.py
Атомарность Lua при гонке, переживание рестарта
Кейсы
не пропускает сверх лимитапачка параллельных запросов на token-bucket / sliding-window — пропущено ровно по лимиту, ни одного сверх: Lua считает атомарно
общий примитив для всеходин примитив обслуживает разные ключи (провайдер, источник) — счётчики не пересекаются между ключами
переживает рестартсостояние лимита живёт в durable Redis — после рестарта процесса счётчик не обнуляется, окно продолжается
режим отказа при недоступностиredis-durable недоступен в момент решения → примитив не проглатывает сбой, а отдаёт его потребителю; безопасный дефолт — fail-closed (защита блокирует перебор), послабление до fail-open потребитель объявляет осознанно (режим отказа)
Unit · P1 Кэш — TTL и ключ fakeredis + time-machine
test_cache.py
Попадание/промах, разделение по ключу, протухание по TTL
Кейсы
попадание / промахпервый запрос — промах, считает и кладёт; повтор тем же ключом — попадание, источник не дёргается
ключ от запроса и identityиная личность или иной запрос дают другой ключ → промах: кандидаты одного не утекают другому через общий кэш (состав ключа)
протухание по TTLпо истечении TTL (time-machine) запись пропадает — следующий запрос промахивается и пересчитывает
кэш не настаиваеткэш — подсказка, не источник истины: при расхождении trim досверяет по источнику, значение из кэша не навязывается
Integration · P1 Кэш — single-flight поднятый Redis · конкурентный промах
test_cache_singleflight.py
Одна пересборка на пачку промахов, замок в durable
Кейсы
один считает, прочие ждутпачка параллельных промахов горячего ключа — дорогой retrieval запускается ровно раз, прочие дожидаются готового результата, источник не пересобирается N раз
замок в durable, не в кэшезамок пересборки (lock:) живёт в redis-durable — LRU вытесняемого кэша его не выбросит под памятью, single-flight не разваливается
замок отпускаетсяпосле пересборки (успех или сбой) замок снимается — следующий промах после TTL берёт его заново, ключ не залипает
Integration · P1 Изоляция лейнов отдельные пулы воркеров
test_lane_isolation.py
Лейн agents не выедает интерактив, падение лейна локально
IntegrationP1 → Три лейна
Кейсы
потолок лейна держитлейн agents со своим потолком насыщен задачами — интерактивный лейн при этом обслуживается, не голодает
падение лейна локальнопадение воркера одного лейна не роняет воркеры другого — топология держит лейны порознь (воркер-топология)
Unit · P1 Cron-окна — таймзона и тик time-machine · детерминированно
test_cron_timezone.py
Naive-окно → UTC по поясу, NULL → пояс организации
Кейсы
окно орг-пояса → UTCnaive HH:MM разворачивается к UTC через platform_settings.timezone (IANA) — тик встаёт на верный момент, а не на локальные часы, принятые за UTC
пояс владельца, NULL → орграсписание агента считается по timezone владельца; NULL падает на пояс организации — не на UTC и не на серверный пояс
граница DSTпересчёт следующего тика через переход летнее/зимнее время (time-machine) не сдвигает окно на час и не теряет тик
Integration · P1 Singleton-планировщик несколько реплик · один тик
test_scheduler_singleton.py
Cron-tick и скан next_run_at: один раз на N реплик, батч наступивших
Кейсы
один тик — одна задачаcron-tick при N репликах планировщика публикует задачу один раз, не N раз (time-machine на cron-окно)
двойной запуск погашенгонка двух реплик на одном тике гасится замком единственности — вторая публикация отбивается (единственность)
скан next_run_at — все наступившиевторой источник триггера: один проход по расписаниям агентов публикует все прогоны, чей next_run_at уже наступил — не первый попавшийся и не по одному за тик (два источника)
пропущенный тик не доганяетсятик, выпавший на простой singleton, при возврате не публикуется задним числом — backfill не делаем, ждём следующего окна (time-machine перескакивает плановое окно) (пропущенный тик)
Integration · P1 pub/sub push-уведомлений fan-out по подписчикам · добор опросом
test_pubsub_push.py
Сигнал доходит до всех подписчиков, пропажа безвредна
IntegrationP1 → pub/sub-канал
Кейсы
веер по подписчикамpublish сигнала получают все висящие подписчики — событие с одной api-реплики доходит до соединения на другой
пропажа безвреднаподписчик пропустил publish (нет соединения в момент события) — следующий опрос добирает счётчик, истина в Postgres не теряется
Integration · P1 Дедуп входящих доставок поднятый Redis · time-machine
test_webhook_dedup.py
Одно событие — одна доставка; окно приёма, ключ в durable
Кейсы
повтор в окне отброшенповторная та же входящая доставка (dedup:webhook: / dedup:slack-event: / dedup:telegram-update:) в окне отбрасывается — событие обрабатывается ровно раз
после TTL проходит сновапо истечении окна приёма (time-machine) отметка гаснет — та же доставка проходит заново, не залипает навсегда
ключ в durable-инстанседедуп-ключ живёт в redis-durable, не в кэше — LRU не выбросит его в окне приёма и повтор не просочится
ортогонально dedup:job:дедуп входящей доставки (событие) и идемпотентность dedup:job: (постановка задачи) — разные namespace, не пересекаются (одно событие — одна доставка)
Integration · P1 Политика памяти durable поднятый Redis · maxmemory
test_memory_policy.py
noeviction: отказ записи вместо тихого вытеснения, backpressure
Кейсы
потолок → отказ, не вытеснениепри достижении maxmemory на redis-durable новая запись отклоняется ошибкой (noeviction) — данные не выкидываются молча
backpressure продьюсерапродьюсер упирается в отказ записи и тормозит — инстанс не растёт без предела и не уходит под OOM-killer
durable переживает давление, cache под LRU уходитпод тем же давлением памяти durable-ключ остаётся на месте, а cache-ключ вытесняется LRU — роли разведены физически, не политикой одного инстанса
Structure Файловая структура тестов

Разделение — по типу. unit/ идёт на каждом PR детерминированно — fakeredis и подменённые часы, без поднятой БД и реального ожидания: логика ключа кэша, идемпотентность, backoff. integration/ — отдельным, более редким шагом на поднятой БД и Redis: единственность и очистка бьют в индекс, rate-limit гонится конкурентно, лейны и планировщик проверяются на реальных пулах. Приоритет (P0–P1) ортогонален каталогам и задаётся маркерами (pytest -m p0).

  • tests/cache_workers/каталог модуля
    • conftest.pyfakeredis / поднятый Redis · time-machine · фабрики задач и прогонов · SAQ-обработчик напрямую
    • unit/детерминированно, каждый PR — без БД и реального ожидания
      • test_enqueue_idempotency.pyповтор job_id и окно после терминала
      • test_retry_backoff.pyклассификация, exp-backoff + jitter, cap и потолок
      • test_cache.pyсостав ключа, изоляция по identity, TTL
      • test_cron_timezone.pyразвёртка cron-окна в UTC, NULL → пояс орг, DST
    • integration/поднятая БД и Redis
      • test_uniqueness_reaping.pyединственность на партиал-UNIQUE индексе, очистка stale по heartbeat, мягкий слив по SIGTERM
      • test_terminal_dlq.py · test_rate_limit.pyтерминал/DLQ потребителя, rate-limit под конкурентной нагрузкой и режим отказа
      • test_cache_singleflight.pyодна пересборка на пачку промахов, замок в durable
      • test_lane_isolation.py · test_scheduler_singleton.pyизоляция лейнов, singleton-планировщик на N репликах
      • test_webhook_dedup.py · test_memory_policy.pyдедуп входящих доставок в окне приёма, noeviction на durable и backpressure
      • test_pubsub_push.pyfan-out сигнала по подписчикам, добор опросом при пропаже