Реализовать cache invalidation

ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Реализовать cache invalidation

1. Цель задачи

Научиться проектировать и реализовывать надёжный механизм инвалидации кэша для контентно-ориентированного приложения. Разработать модуль, который при обновлении любого документа автоматически удаляет все кэш-записи, так или иначе связанные с этим документом. В результате пользователи всегда получают актуальные данные, а устаревшие (stale) данные полностью исключены.

Ключевой результат Реализованная система инвалидации кэша, при которой после обновления документа все связанные кэши удаляются, а повторное обращение возвращает свежие данные (начиная с тестов, показывающих отсутствие stale data).

2. Исходные данные

Что нужноОткуда взять
Работающее веб-приложение с документами (например, блог или заметки)Учебный проект (Django / Flask / FastAPI), пет-проект или репозиторий курса
Redis (или in-memory кэш)Локальный запуск через Docker (docker run -p 6379:6379 redis:7) или мок-заглушка
Документация по API эндпоинтам (GET /documents/{id}, PATCH /documents/{id} и т.д.)Swagger / OpenAPI или README проекта
Тестовые данные (минимум 3 документа, 10+ кэшируемых запросов)Сгенерировать через скрипт или использовать существующие данные из staging-среды
Базовые тесты кэша (есть, кэшируются ли ответы)Написать самому или взять из существующего репозитория

Если нет реального инструмента — симулируем:

  1. Развернуть простое FastAPI приложение с эндпоинтами:
    • GET /documents/{id} — возвращает документ (кэшируется на 1 минуту)
    • PATCH /documents/{id} — обновляет документ в БД (SQLite)
    • GET /documents/search?q=... — поиск по документам (кэшируется на 2 минуты)
  2. Redis запустить в Docker, подключить через redis-py.
  3. Написать скрипт, который наполняет БД 5–10 документами и выполняет несколько типовых запросов, чтобы заполнить кэш.
  4. Настроить логгирование — запись всех обращений к кэшу (get/set/delete).

3. Технологический стек

КомпонентИнструментыНазначение
Язык программированияPython 3.11+Основной язык реализации
Веб-фреймворкFastAPI / FlaskREST приложение для работы с документами
Кэш-брокерRedis (через docker)Хранение кэшированных ответов
Клиент Redisredis-py (>=5.0)Взаимодействие с Redis из Python
База данныхSQLite / JSON-файлПостоянное хранилище документов
Тестированиеpytest + fakeredisЮнит- и интеграционные тесты
Логгированиеlogging (stdout + file)Отслеживание операций инвалидации
CI/CD (опционально)GitHub ActionsАвтоматический прогон тестов

4. Этапы выполнения

Этап 1: Анализ и проектирование схемы инвалидации (1–2 часа)

Действия

  1. Инвентаризация всех кэшируемых эндпоинтов

    • Выписать все пути, которые кэшируются: GET /documents/{id}, GET /documents/search?q=..., GET /documents/{id}/related, а также любые агрегированные (например, GET /documents/stats).
    • Определить, какие из них зависят от конкретного документа.
  2. Разработка схемы ключей Redis

    • Для каждого документа ввести тегированный ключ: doc:{id}:* — все дочерние ключи.
    • Использовать один из подходов:
      • Direct mapping ключи явно содержат ID документа (например, doc:42:{endpoint}).
      • Tag-based ключи хранят дополнительный тег (например, SET tags:doc:42 "doc:42:detail").
    • Рекомендуемый способ: ключи с префиксом doc:{id}: (простой, гибкий).
  3. Выбор алгоритма инвалидации

    • При PATCH /documents/{id}:
      • Удаляем все ключи по шаблону doc:{id}:*.
      • Если есть общие кэши (например, поиск, который может включать документ) — инвалидируем их отдельно, либо используем SCAN по паттерну search:* и проверяем содержимое (сложнее).
    • Для простоты на первом этапе: инвалидируем только прямые кэши документа.
    • Запланировать расширение на этапе 3 (управление зависимостями).
  4. Документирование принятых решений

    • Описать схему ключей, какие именно кэши удаляются, а какие остаются.

Ожидаемый результат этапа Документ DESIGN.md с описанием инвентаризации эндпоинтов, схемы ключей и выбранного алгоритма инвалидации.

Этап 2: Реализация базовой инвалидации (2–3 часа)

Действия

  1. Добавить слушатель события обновления документа

    • В функции update_document (PATCH /documents/{id}) после успешного обновления в БД вызывать функцию invalidate_caches(doc_id).
  2. Написать функцию invalidate_caches

    import redis
    
    r = redis.Redis(host='localhost', port=6379, decode_responses=True)
    
    def invalidate_caches(doc_id: int):
        pattern = f"doc:{doc_id}:*"
        keys = r.keys(pattern)
        if keys:
            r.delete(*keys)
            logger.info(f"Invalidated {len(keys)} cache keys for doc {doc_id}")
        else:
            logger.info(f"No cache keys found for doc {doc_id}")
    
  3. Добавить декоратор кэширования (если его нет) Реализовать простой декоратор, который добавляет ключ с префиксом doc:{doc_id}:{func_name}:{params_hash}:

    def cache_response(doc_id_field: str = "doc_id"):
        def decorator(func):
            @functools.wraps(func)
            async def wrapper(*args, **kwargs):
                doc_id = kwargs.get(doc_id_field) or args[0]
                cache_key = f"doc:{doc_id}:{func.__name__}:{hash(tuple(sorted(kwargs.items())))}"
                # ... стандартная логика get/set
            return wrapper
        return decorator
    
  4. Подключить логгирование — записывать каждое удаление кэша с указанием ключей и времени.

Ожидаемый результат этапа Функция invalidate_caches интегрирована в эндпоинт обновления, кэш удаляется при каждом PATCH. Работоспособность проверена вручную (через логи или Redis CLI).

Этап 3: Расширение инвалидации на зависимые кэши (1–2 часа)

Действия

  1. Анализ зависимостей поискового кэша

    • GET /documents/search?q=... может возвращать документ по id. Если документ обновлён, старый результат поиска содержит устаревшие данные.
    • Простейший способ: при обновлении документа также удалять все ключи с префиксом search:* (полная инвалидация поиска).
    • Альтернатива: хранить в каждом ключе поиска список doc_id, которые он содержит, и удалять только те ключи, которые включают изменившийся документ. Для первой версии используем полную инвалидацию.
  2. Реализация полной инвалидации поиска

    def invalidate_search_cache():
        keys = r.keys("search:*")
        if keys:
            r.delete(*keys)
            logger.info(f"Invalidated {len(keys)} search cache keys")
    
    • Вызывать invalidate_search_cache() внутри invalidate_caches.
  3. Обработка связанных документов (Related, Stats)

    • Аналогично: если есть GET /documents/{id}/related, его ключи начинаются с doc:{id}:related:* — они уже покрываются паттерном doc:{id}:*.
    • Для статистики GET /documents/stats, которая агрегирует все документы: очищать отдельно через keys("stats:*").
  4. Добавление стратегии тегирования (tag-based) — опционально

    • Вместо ручного удаления по паттерну использовать Redis Tags (например, добавить ключ tag:doc:42, в котором хранить множество ключей, связанных с этим doc). При инвалидации читать этот тег и удалять все ключи из множества. Этот подход масштабируется, но сложнее.

Ожидаемый результат этапа Инвалидация охватывает не только прямые кэши документа, но и кэш поиска (полностью), а также статистику. При обновлении документа все связанные кэши удаляются.

Этап 4: Тестирование и проверка отсутствия stale data (2–3 часа)

Действия

  1. Написать юнит-тесты (с fakeredis)

    • Проверить, что invalidate_caches удаляет правильные ключи.
    • Проверить, что после вызова invalidate_caches и повторного GET возвращается свежий ответ (мокаем БД).
    import fakeredis
    import pytest
    
    @pytest.fixture
    def redis_client():
        return fakeredis.FakeRedis(decode_responses=True)
    
    def test_invalidation_removes_correct_keys(redis_client):
        redis_client.set("doc:1:detail", "old data")
        redis_client.set("doc:1:search_hash", "old search")
        # ... вызов invalidate_caches
        assert redis_client.keys("doc:1:*") == []
    
  2. Написать интеграционные тесты (реальный Redis, Docker)

    • Запустить приложение и Redis в Docker Compose.
    • Последовательно выполнить: GET (кэшируется), PATCH, GET — проверить, что второй GET не отдаёт stale data.
    • Повторить для поискового кэша.
  3. Тест на конкуренцию (race condition)

    • Одновременный PATCH и несколько GET запросов (через threading или asyncio).
    • Убедиться, что система не падает и в конечном итоге stale data не задерживается надолго (допустима короткая консистентность «в конечном счёте»).
  4. Тест на частичное обновление

    • Обновить только заголовок документа, проверить, что кэш данных документа удалён, а кэш поиска, содержащий другой документ, не тронут (если реализована точная инвалидация, а не полная очистка поиска).

Ожидаемый результат этапа Все тесты проходят, доказано, что stale data не возникает. Написаны как минимум 4 интеграционных и 5 юнит-тестов.

Этап 5: Оптимизация и обработка граничных случаев (1 час)

Действия

  1. Обработка ситуации «нет кэша» — функция invalidate_caches должна отрабатывать без ошибок, если ключей нет.
  2. Ограничение размера операции — если ключей много (10 000+), keys() может блокировать Redis. Использовать SCAN с пагинацией.
    def invalidate_caches(doc_id: int):
        pattern = f"doc:{doc_id}:*"
        cursor = 0
        while True:
            cursor, keys = r.scan(cursor, match=pattern, count=100)
            if keys:
                r.delete(*keys)
            if cursor == 0:
                break
    
  3. Добавить метрики (опционально) — счётчик инвалидаций, время выполнения.
  4. Документация по стратегии инвалидации — в README проекта описание, как расширять для новых типов кэша.

Ожидаемый результат этапа Решены проблемы производительности, обработка пустых множеств, добавлена документация.

5. Критерии приемки (Definition of Done)

  • После обновления документа все прямые кэши (detail, related, и т.п.) удаляются не позднее чем через 1 секунду после успешного PATCH.
  • Кэш поиска (или другой агрегированный кэш), содержащий обновлённый документ, также инвалидируется (полностью или частично).
  • При повторном GET по любому из инвалидированных кэшей возвращаются актуальные данные (свежий ответ из БД).
  • Написаны автоматические тесты (не менее 10 штук), покрывающие основные сценарии (обновление, удаление, конкуренция, отсутствие кэша).
  • Реализация использует безопасную итерацию (SCAN) для большого количества ключей — не блокирует Redis.
  • Логирование операций инвалидации включено и содержит понятные сообщения о количестве удалённых ключей.

6. Ожидаемый результат

  • Артефакт код в репозитории (ветка feature/cache-invalidation).
  • Состав
    • src/cache/invalidation.py — функция invalidate_caches (и опционально invalidate_search_cache).
    • src/cache/caching.py — декораторы кэширования (если переработаны).
    • tests/test_invalidation.py — юнит-тесты (+ conftest с фабриками).
    • tests/integration/test_invalidation_integration.py — интеграционные тесты (с Docker).
    • DESIGN.md — документ с описанием схемы ключей и алгоритма.
    • README.md — обновлённый раздел про кэширование.
  • Метрики успеха
    • Все тесты проходят.
    • Ручная проверка: после PATCH -> GET возвращает свежий ответ.
    • В логах видны записи типа Invalidated 3 cache keys for doc 42.

7. Возможные сложности и их решение

СложностьРешение
KEYS блокирует Redis при большом количестве ключейЗаменить на SCAN с пагинацией (см. Этап 5).
Инвалидация поиска слишком грубая (удаляет всё)Внедрить теги: при запросе поиска записывать в отдельный ключ search:q:... множество doc_id. При инвалидации документа проверять, входит ли он в это множество, и удалять конкретный ключ поиска.
Конкурентные обновления одного документа (два PATCH подряд)Использовать Redis транзакции (MULTI/EXEC) или Lua-скрипты для атомарности. Или реализовать Optimistic Locking через WATCH.
Stale data остаётся из-за кэша на стороне CDN / браузераДобавить заголовки Cache-Control: no-cache на ответы с недавно обновлёнными документами (или короткий TTL).
Отсутствие времени на полную реализациюСобрать MVP: инвалидация прямых кэшей + полная очистка поиска. Этого достаточно для прохождения критериев приемки.

8. Бюджет времени (оценка)

ЭтапВремя
Этап 1: Анализ и проектирование1–2 часа
Этап 2: Базовая инвалидация2–3 часа
Этап 3: Расширение на зависимые кэши1–2 часа
Этап 4: Тестирование2–3 часа
Этап 5: Оптимизация и граничные случаи1 час
Итого7–11 часов (2 рабочих дня)

Примечание Если это первый опыт с Redis и инвалидацией, заложите дополнительно 2–4 часа на отладку.

9. Связанные вопросы из базы знаний

ВопросТема
42Какие существуют стратегии инвалидации кэша? (Write-through, write-behind, TTL, событийная)
73Как избежать "cache stampede" при массовой инвалидации?
128Разница между KEYS и SCAN в Redis
219Проектирование ключей Redis для кэширования запросов
345Как тестировать кэш с помощью fakeredis?
478Обработка race condition при одновременном чтении и записи в кэш
512Tag-based инвалидация: реализация через Redis Sets
689Мониторинг и метрики для кэша (hit ratio, invalidation count)
777Инвалидация пагинированных и агрегированных запросов
890Интеграция Redis с FastAPI: лучшие практики

10. Чек-лист самопроверки

  • Я явно описал схему ключей и выбрал подходящий паттерн инвалидации (префикс или теги).
  • Я реализовал функцию invalidate_caches, которая удаляет все ключи, начинающиеся с doc:{doc_id}:*, и (опционально) полную очистку поиска.
  • Я заменил KEYS на SCAN для production‑готовности.
  • Я написал как минимум 5 юнит‑тестов и 2 интеграционных, подтверждающих отсутствие stale data.
  • Я проверил, что при обновлении документа лог содержит запись об инвалидации с количеством удалённых ключей.