Поиск повторяющихся запросов (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):
...
Запуск детектора¶
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 он на практике не похож.