← все задачи

Async Python · задача 8 из 10

Фоновая задача по расписанию

Средний 15–20 минут create_taskжизненный циклустойчивость

Условие

Нужно каждые 30 секунд обновлять кэш курсов валют в фоне, не мешая обработке запросов. Задача должна переживать ошибки и корректно останавливаться при завершении приложения.

Что требуется

  • Запускается при старте приложения, останавливается при завершении
  • Ошибка в одной итерации не прекращает работу цикла
  • Остановка не оставляет висящих задач и предупреждений

Пример

@asynccontextmanager
async def lifespan(app):
    task = asyncio.create_task(refresh_loop(30))
    yield
    task.cancel()

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Интервал между запусками или строго «раз в 30 секунд» независимо от длительности работы?
  • Сколько инстансов приложения — если несколько, обновлять кэш будет каждый, нужен ли лидер?
  • Что делать, если обновление не успевает за интервал?
Показать решение Скрыть решение

Решение

import asyncio
import logging
from contextlib import asynccontextmanager

logger = logging.getLogger(__name__)


async def refresh_loop(interval, refresh):
    while True:
        try:
            await refresh()
        except asyncio.CancelledError:
            logger.info("фоновое обновление остановлено")
            raise
        except Exception:
            # одна неудача не должна убивать цикл навсегда
            logger.exception("не удалось обновить кэш")

        await asyncio.sleep(interval)


@asynccontextmanager
async def lifespan(app):
    task = asyncio.create_task(refresh_loop(30, refresh_rates))
    try:
        yield
    finally:
        task.cancel()
        await asyncio.gather(task, return_exceptions=True)

Почему так

Почему except Exception внутри цикла

  • Исключение, вылетевшее из корутины задачи, завершает её — обновление просто перестанет происходить
  • Заметить это тяжело: приложение работает, кэш тихо устаревает, и никто не получает ошибку
  • Ловим и логируем внутри итерации, чтобы цикл дожил до следующего раза

Почему ссылку на задачу нужно хранить

  • create_task не удерживает задачу: цикл событий хранит только слабую ссылку, и сборщик мусора может забрать её на середине
  • Поэтому task кладут в переменную или в множество на всё время жизни — это известная ловушка из документации
  • Хранение ссылки заодно даёт возможность отменить задачу при остановке

Почему отмена и gather при завершении

  • Без cancel задача останется висеть, а интерпретатор при выходе напечатает «Task was destroyed but it is pending»
  • gather после cancel даёт задаче завершиться: отработают finally и закроются соединения
  • return_exceptions=True нужен, чтобы ожидаемый CancelledError не всплыл при штатной остановке

Что спросят дальше

  • Спросят про несколько инстансов: каждый будет обновлять своё — нужен лидер, блокировка в Redis или вынос в Celery beat
  • Спросят про «строго раз в 30 секунд»: интервал между запусками плывёт на длительность работы, для точности считают следующий момент от начала
  • Спросят, чем это хуже Celery beat: нет персистентности, при рестарте расписание начинается заново

Следующая задача

Асинхронный итератор по страницам API — Обёртка над постраничным API так, чтобы снаружи это был обычный async for.