Перейти к содержанию

Поиск повторяющихся запросов (N+1)

Детектор, который во время работы считает одинаковые по форме запросы одной единицы работы и сообщает, когда их число в скользящем окне времени достигает порога. Это наблюдатель QueryExecuted, поэтому никаких изменений в диалектах ему не нужно.

class RepeatedQueryDetector:
    def __init__(
        self, *, threshold: int = 10, window_seconds: float = 1.0, action: ReportAction | str = "warn"
    ) -> None: ...

    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    def get_counts(self) -> dict[str, ShapeStats]: ...

    @classmethod
    def reset_counts(cls) -> None: ...

Детектор на весь процесс или на один блок:

from hare.contrib.repeated_queries import RepeatedQueryDetector

detector = RepeatedQueryDetector(threshold=10, window_seconds=1.0, action="warn")
await detector.start()  # один раз, при запуске процесса или приложения
...
await detector.stop()

# или на время блока:
async with RepeatedQueryDetector(threshold=10, window_seconds=1.0):
    ...
async def handle_job():
    RepeatedQueryDetector.reset_counts()  # новая единица работы — см. ниже
    ...

Запуск детектора

start() добавляет наблюдателя детектора на весь процесс (повторный start() того же детектора ничего не делает, как и stop() незапущенного). Каждый детектор — отдельный экземпляр со своими настройками и счётчиками: два детектора с разными порогами работают рядом. detector.get_counts() — его счётчики в текущей единице работы: ключ формы → ShapeStats(sql_sample, window_start, count) текущего окна этой формы.

Формы и отчёты

Текст SQL каждого запроса — с местами для параметров, без самих значений — приводится к ключу «формы» (RepeatedQueryDetector.shape_key() — только нормализация пробелов, без разбора) и считается по формам в пределах единицы работы. Значения — это заполнители, поэтому тот же запрос с другими значениями — одна форма; но список __in другой длины даёт столько же заполнителей больше или меньше и это другая форма (id IN ($1, $2) и id IN ($1, $2, $3)); список, достаточно длинный, чтобы его связали одним массивом (= ANY($1) в PostgreSQL), — одна форма при любой длине. Когда счётчик формы достигает threshold в пределах одного окна window_seconds, action срабатывает ровно один раз на окно — а не на каждый следующий вызов — и получает RepeatedQueryReport(shape_key, count, threshold, window_seconds, sql_sample, call_site). call_site — стек кода приложения, отправившего запрос, на котором порог был достигнут, до самого внутреннего кадра вне hare: наблюдатель выполняется прямо в задаче, которая отправила запрос.

Настройки

threshold — сколько запросов одной формы за window_seconds считаются повторами; форма, не набравшая порога за это время после первого запроса, считается заново. action="warn" (по умолчанию) пишет предупреждение через hare.core.log.logger.warning; вместо этого можно передать функцию (Callable[[RepeatedQueryReport], None]) — метрику или структурированное событие журнала. Исключение из неё записывается в журнал и до запроса не доходит — чтобы тест падал на N+1, проверяйте collect(). Любая другая строка — ConfigurationError.

Единицы работы

Своего понятия «запроса» у hare нет. Интеграции с FastAPI, Litestar и Robyn вызывают RepeatedQueryDetector.reset_counts() в начале каждого запроса; в остальных местах — в обработчике заданий, в цикле потребителя — вызывайте его в начале каждой единицы работы, в задаче, которая её выполняет. С этого момента каждый детектор считает с нуля в этой задаче и в запущенных из неё задачах, которые добавляют к тем же счётчикам. Без reset_counts() счёт задачи начинает её первый запрос, поэтому у долгоживущей задачи счётчики растут через то, что вы считали бы отдельными единицами.

Подсчёт внутри блока: collect()

class RepeatedQueryDetector:
    @staticmethod
    def shape_key(sql: str) -> str: ...

    @staticmethod
    def collect() -> RepeatedQueryCollection: ...

# `with` и `async with`
class RepeatedQueryCollection:
    # все посчитанные запросы
    total: int
    entries_by_shape_key: dict[str, RepeatedQueryEntry]

    # в порядке первого появления
    def __iter__(self) -> Iterator[RepeatedQueryEntry]: ...

    # число различных форм
    def __len__(self) -> int: ...

    # 0 для невиданной формы
    def count_for(self, sql_or_shape_key: str) -> int: ...

    # самые частые первыми
    def repeated(self, threshold: int = 2) -> list[RepeatedQueryEntry]: ...


@dataclass
class RepeatedQueryEntry:
    shape_key: str
    count: int
    # первый SQL этой формы
    sql_sample: str
    # стек первого запроса этой формы, до самого внутреннего кадра вне hare
    call_site: str

Считает запросы по формам для одного блока кода — «сколько одинаковых запросов сделал этот запрос к API». Не требует ни детектора, ни reset_counts() и работает независимо от того, запущен ли какой-нибудь детектор. Обычное применение — проверить, что у эндпоинта нет N+1:

async with RepeatedQueryDetector.collect() as collection:
    await client.get("/books")
assert collection.repeated(threshold=2) == [], [entry.sql_sample for entry in collection.repeated()]

collect() наблюдает QueryExecuted через Observers.observing(), поэтому подсчёт идёт синхронно в задаче, отправившей запрос: к выходу из блока каждый его запрос уже посчитан (wait_for_pending() не нужен), а call_site указывает на настоящий кадр приложения. with и async with ведут себя одинаково — вход и выход не выполняют ввода-вывода. Поведение:

  • Параллельные задачи, у каждой свой collect(), никогда не видят запросов друг друга.
  • Вложенные блоки: запрос во внутреннем блоке считается и во внутреннем блоке, и во всех внешних.
  • Дочерние задачи, созданные внутри блока (asyncio.create_task, gather корутин, ...), наследуют контекст и тоже считаются — они работают от имени блока. Их запросы после выхода из блока не считаются.
  • Один объект подсчёта нельзя войти повторно, пока он активен (QueryError); repeated() отвергает threshold < 1 с QueryError.
  • Выход в другой задаче, чем вход (например, асинхронная фикстура-генератор pytest, чей teardown выполняется в другой задаче), не вызывает ошибки: подсчёт закрывается, перестаёт считать, а его наблюдатель убирается из контекста задачи, которая выходит.

shape_key() — публичная нормализация, общая для обоих режимов (повторное применение ничего не меняет, поэтому count_for() принимает и текст SQL, и ключ формы).

Эвристика по тексту SQL

«Форма» — это сам текст SQL с местами для параметров, а не какой-либо структурный ключ, который hare строит внутри. Это практичная эвристика, и на практике она работает хорошо: обычные значения фильтров передаются параметрами, а не вписываются в текст SQL, и при кэшированной, и при обычной сборке запроса, поэтому два вызова, различающиеся только значениями фильтров, дают одну форму. Это касается и имени текущего часового пояса в операторах фильтра __year/__month/... вида EXTRACT(... AT TIME ZONE 'zone'): оно, как и любое другое значение, попадает в итоговый SQL параметром, поэтому два одинаковых фильтра с учётом часового пояса, выполненные при разных настроенных поясах, здесь намеренно считаются одной формой. Массовая загрузка COPY считается как любой другой запрос, по тексту COPY <table> (<columns>) FROM STDIN — по одному на партию, на N+1 он на практике не похож.