Тестируем конвенции слоя, не библиотеки
Redis и SAQ как библиотеки доверены их авторам — здесь проверяются
наши правила поверх них: единственность задачи, очистка
протухших прогонов, ретраи и backoff, политика кэша, изоляция
лейнов. Не дублируем тесты брокера и драйвера.
Единственность и очистку — на реальной БД и поддельном
времени
Замок одного активного прогона держит партиал-UNIQUE индекс
Postgres, а не проверка в коде — значит, тест поднимает БД и бьёт
в индекс. Протухание heartbeat_at и окна TTL/cron
гоним через подменённые часы (time-machine), не
реальным ожиданием.
Rate-limit Lua — под конкурентной нагрузкой
Атомарность token-bucket / sliding-window видна только при гонке:
тест шлёт параллельные запросы и проверяет, что сверх лимита не
просочилось ни одного — последовательная проверка такой баг не
ловит.
Стек и инфраструктура
Имеется donepytest, pytest-asyncio
Redisfakeredis для 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 отбрасывается, окно после терминала
второй активный отвергнутпри ещё активном прогоне попытка второго отбивается
партиал-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
итог не теряетсярезультат прогона записан в Postgres-строку модуля-потребителя
— durable, переживает завершение задачи в брокере
падение по политикеисчерпавшее попытки падение пишется по политике потребителя —
DLQ-таблица / статус «не доставлено» / failed-items
— а не в durable Redis-очередь
очередь не копит мёртвоеRedis-очередь не накапливает терминальные задачи — после
терминала запись о ней живёт в Postgres, не в брокере
Integration · P1Rate-limit под нагрузкойподнятый Redis · конкурентно
не пропускает сверх лимитапачка параллельных запросов на token-bucket / sliding-window
— пропущено ровно по лимиту, ни одного сверх: Lua считает
атомарно
общий примитив для всеходин примитив обслуживает разные ключи (провайдер, источник)
— счётчики не пересекаются между ключами
переживает рестартсостояние лимита живёт в durable Redis — после рестарта
процесса счётчик не обнуляется, окно продолжается
режим отказа при недоступностиredis-durable недоступен в момент решения → примитив не
проглатывает сбой, а отдаёт его потребителю; безопасный дефолт
— fail-closed (защита блокирует перебор), послабление до
fail-open потребитель объявляет осознанно
(режим отказа)
Unit · P1Кэш — TTL и ключfakeredis + time-machine
test_cache.py
Попадание/промах, разделение по ключу, протухание по TTL
один считает, прочие ждутпачка параллельных промахов горячего ключа — дорогой
retrieval запускается ровно раз, прочие дожидаются готового
результата, источник не пересобирается N раз
замок в durable, не в кэшезамок пересборки (lock:) живёт в
redis-durable — LRU вытесняемого кэша его не
выбросит под памятью, single-flight не разваливается
замок отпускаетсяпосле пересборки (успех или сбой) замок снимается — следующий
промах после TTL берёт его заново, ключ не залипает
окно орг-пояса → UTCnaive HH:MM разворачивается к UTC через
platform_settings.timezone (IANA) — тик встаёт на
верный момент, а не на локальные часы, принятые за UTC
пояс владельца, NULL → орграсписание агента считается по timezone
владельца; NULL падает на пояс организации — не на
UTC и не на серверный пояс
граница DSTпересчёт следующего тика через переход летнее/зимнее время
(time-machine) не сдвигает окно на час и не теряет тик
Integration · P1Singleton-планировщикнесколько реплик · один тик
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 · P1pub/sub push-уведомленийfan-out по подписчикам · добор опросом
test_pubsub_push.py
Сигнал доходит до всех подписчиков, пропажа безвредна
повтор в окне отброшенповторная та же входящая доставка (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 сигнала по подписчикам, добор опросом при
пропаже