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

Кэш планов запросов

hare сохраняет устройство запроса — его вид (shape), — чтобы при повторном вызове той же цепочки .filter()/.annotate()/.order_by() с другими значениями не обходить заново дерево фильтров, не разбирать заново каждый оператор и не строить с нуля объект запроса hare.sql. Это полностью внутренняя оптимизация: для неё нет опции Meta и аргумента Hare.init() — единственная настройка — переменная окружения, ограничивающая размер кэша (см. Границы и время жизни ниже). Эта страница объясняет, что кэш покрывает и что нет, чтобы поведение запроса никогда не было загадкой.

Главное: вид и значения

У двух вызовов один вид, если они строят одинаковое устройство фильтров, вычисляемых значений, соединений и сортировки — те же поля, те же операторы, те же присоединённые связи — и отличаются только конкретными значениями, подставленными в это устройство:

await Book.objects.filter(title__icontains="dispossessed")
await Book.objects.filter(title__icontains="left hand")   # тот же вид, другое значение

Первый вызов строит полный объект запроса hare.sql (дерево WHERE, JOIN, список SELECT, план чтения строк), один раз выводит его в SQL и сохраняет результат как план вида под ключом из всего, что составляет вид: текст SQL, в какой параметр идёт каждое значение запроса и что нужно для чтения результата. Второй вызов узнаёт тот же вид и выполняет текст SQL плана со своими значениями — не разбирая заново Q/F/вычисляемые значения, не трогая граф соединений и не выводя SQL.

Запрос вида, у которого есть план, собирается полностью, когда выполнить его по плану нельзя: запрос, показанный через .sql()/explain() или встроенный в другой запрос, у которого нет своего плана, и запрос со значением, меняющим текст SQL (значение, преобразующееся в другой тип, LIMIT только с одной стороны). План остаётся для следующего запроса. Значение фильтра None и логическое значение __isnull входят в вид: у запросов IS NULL/IS NOT NULL свои планы.

Главный практический случай — постраничный вывод: значения LIMIT/OFFSET никогда не входят в ключ вида и привязываются при каждом вызове заново (в ключ входит только то, есть ли у запроса LIMIT и OFFSET вообще), поэтому листание одного и того же отфильтрованного и отсортированного списка — едва ли не самый частый запрос в приложении — попадает в кэш на каждой странице:

base = Book.objects.filter(in_print=True).order_by("-published_at")
await base.limit(20).offset(0)     # строит и сохраняет вид
await base.limit(20).offset(20)    # тот же вид, попадание в кэш
await base.limit(20).offset(40)    # тот же вид, попадание в кэш

Границы курсора по JSON-полям тоже привязывают новые значения к сохранённому плану по тем же правилам сортировки JSON, что и order_by(). Это относится к after_cursor() и before_cursor(), включая запросы values(), count() и exists().

Что входит в вид (ключ кэша)

Класс модели, диалект, имя подключения, построитель запросов, к которому привязана модель, какие колонки и связи выбираются (select_related/.only()/.defer()/.values()), сортировка, distinct(), устройство дерева WHERE (вид соединения условий, отрицание, какое поле и оператор на каком месте — но никогда не сравниваемое значение), имена и устройство выражений вычисляемых значений (никогда не их константы) и является ли каждое из них .alias(), флаги select_for_update(), сколько граничных значений .after_cursor()/.before_cursor() задано и какие из них None (граница None — это проверка IS NULL/IS NOT NULL без значения), взят ли срез строк (есть ли LIMIT, есть ли OFFSET — но не их значения), а также пояс, который запрос пишет в свой SQL: имя текущего часового пояса при use_timezone, собственный пояс машины (как его называет tzlocal) без него — почему, см. Правильность ниже.

Model.objects.get(**filters) при любых параметрах исключений выполняется по той же записи, что и QuerySet, который он заменяет: она находится по ключам фильтров и типам их значений — см. get(). Так же работают модели с Meta.soft_delete_field или Meta.tenant_field (фильтры области по умолчанию входят в ключ, а привязывается арендатор, активный в момент выполнения запроса) и get() после select_for_update(), include_deleted(), only_deleted(), all_tenants() и using() — блокировка и видимые строки входят в ключ. get() с обычными значениями — отдельный вызов; с None, булевым значением __isnull или списком __in он становится вызовом filter(), который заменяет. Как и любой QuerySet, соединение он выбирает при await.

Queryset, полученный из Model.objects одними простыми вызовами — filter()/exclude(), в том числе с условиями Q(...) и Exists(...) (filter(Q(pages=3) | Q(title="x")), связки и отрицания дерева входят в ключ) и со значениями обычными, выражениями (F("other") + 1) или запросами (id__in=Subquery(...), QuerySet), annotate()/alias() с выражениями, distinct(), order_by() по именам полей, limit()/offset(), first()/last(), get(), only(), select_related(), values()/values_list() по именам полей, group_by(), prefetch_related() и count()/exists()/ aggregate() в конце, — выполняется по записи, найденной по самим вызовам: его фильтры даже не строятся. Запрос моделей без присоединённых связей и без вычисляемых значений или запрос values()/values_list() выполняется вовсе без объекта запроса — строки читаются так, как запись хранит для их формы. Значения каждого вызова — фильтра, литералы выражения — находятся среди значений записи по тому, откуда они взяты, поэтому порядок значений самой записи (сначала вычисляемые значения, потом фильтры) не обязан совпадать с порядком вызовов. Вызов prefetch_related() входит в ключ списком подгружаемых связей — колонка ключа прямой связи выбирается для подгрузки даже после only(), — а запросы подгрузки выполняются так же, как у любого QuerySet. Связь, прочитанная у объекта (author.books.filter(...)), — тоже цепочка вызовов, ключ владельца привязывается как значение. Любой другой вызов (терм hare.sql, собранный вручную, как значение) оставляет QuerySet обычному описанию выше. Запись та же самая: цепочка вызовов находит запись, построенную её первым запросом, а запись, вытесненную из кэша, по вызовам тоже не найти.

Прямая связь, сравниваемая со значением ключа (author=3, author__gt=3), с объектом (author=obj) или со списком ключей или объектов (author__in=[...]), сохраняет свою запись: объект заменяется своим ключом и по типу отличается от значения ключа, поэтому у него своя запись. Связь с составным ключом сравнивает каждую колонку ключа: объект или кортеж ключа подставляется по колонкам. Обобщённый внешний ключ (target=obj, target__in=[...], target__type="book", target__type__in=[...], target__isnull=True) сравнивает ветку, которую называет значение, — имя ветки входит в ключ. Фильтр по имени связи «ко многим» (tags=obj, tags__in=[...], books=obj) и чтение связи «многие ко многим» (obj.tags.all()) сохраняют свою запись — объект заменяется своим первичным ключом; так же сохраняют её pk__in=[(a, b), ...] составного первичного ключа (по числу строк), __iexact, __search в PostgreSQL, фильтры по пути JSON (data__a=, data__a__gt=, data__a__in=) и проверка вхождения JSON в обоих диалектах.

__range с одной открытой границей или без границ ((None, 10), (None, None)) хранит запись для каждой открытой стороны, а оставшаяся граница — значение. JSON __filter= хранит запись для ключей своего словаря и типов его значений, а сами значения подставляются при каждом вызове; __has_keys= и __has_any_keys= — для числа ключей. .contains(obj) хранит запись так же, как exists(), а ключ объекта подставляется при каждом вызове.

Фильтр, значение которого — запрос (field__in=Other.objects.filter(...).values_list("id", flat=True), голый QuerySet — сравнивается по первичному ключу, Subquery(...)), хранит запись вместе с подзапросом: в ключ входит собственный вид подзапроса, а его значения, включая LIMIT/OFFSET, привязываются вместе со значениями внешнего запроса. Это верно и для подзапроса с фильтром по оконной функции и для объединения. Подзапрос, вид которого не может иметь своей записи (см. таблицу ниже), собирается каждый раз, и вместе с ним — запрос, значением которого он служит.

Запрос с CTE (.with_cte(name, query)) — запрос модели, .values()/.values_list(), count(), exists(), aggregate() — хранит запись вместе с ними так же: в ключ входит вид тела каждого CTE, а значения тела привязываются вместе со значениями самого запроса. Значение фильтра RawSQL (документированный способ прочитать CTE, filter(id__in=RawSQL('SELECT "id" FROM "cte"'))) входит в вид своим текстом SQL, а его parameters — значения. update() и delete() с CTE хранят запись так же.

Операция над множествами — union(), intersection(), difference() querysets или запросов .values()/.values_list(), а также count()/exists() над ней — хранит запись всего общего запроса: в ключ входит вид каждой ветки (включая вложенную операцию), сами операции, сортировка и взят ли срез; значения всех веток и среза привязываются при каждом вызове. Операция с веткой, вид которой не может иметь записи, собирается каждый раз.

Запрос, вызывающий методы QuerySet диалекта — .final() или .limit_by(3, "author_id") ClickHouse, — хранит запись с этими вызовами, записанными в текст SQL: в ключ входят имя и аргументы каждого метода, так что .limit_by(3, "author_id") и .limit_by(5, "author_id") — два разных вида. Вызов с аргументом, который не может входить в ключ (список, словарь), собирает запрос каждый раз. Запрос, выполняемый по записи, всё равно проверяет, что диалект его подключения реализует каждый вызванный метод.

count(), exists() и aggregate() над строками, которые возвращает запрос .values()/ .values_list() или операция над множествами — со срезом ([2:5].count()), с .distinct(), — хранят запись всего запроса над этими строками: в ключ входит вид запроса строк (и устройство каждой метрики aggregate()), а его значения и OFFSET, который пропускает exists(), привязываются при каждом вызове. Метрика aggregate() над такими строками со своим значением (Sum("price", _filter=Q(...)), Coalesce(Max("price"), 0)) подставляет свои значения после значений запроса строк.

aggregate() хранит запись, как бы он ни собирался: по прежним ключам .annotate()/.alias(), которые читают метрики или фильтры, через сгруппированную производную таблицу для агрегатного вычисляемого значения (.annotate(n=Count(...)).aggregate(Max("n"))) или .distinct(), после .group_by(), с границей курсора. Какие вычисляемые значения разрешает каждый шаг сборки, следует из вида и хранится в записи, поэтому следующий запрос перечисляет свои значения в том же порядке, ничего не разрешая. После .group_by() собственные значения метрики подставляются так же.

Вычисляемое значение, ссылающееся на другое по имени, — F("bumped") * 2, Sum("bumped"), фильтр bumped__gt=..., partition_by=/order_by= окна или поле Lag/Lead, — хранит запись: разрешение имени разрешает то значение ещё раз, и его значения привязываются в каждом месте, где оно разрешено.

Оконная функция хранит запись, что бы она ни вычисляла, — RowNumber(), CumeDist(), PercentRank(), NthValue(...), NTile(n), Lag/Lead со значением по умолчанию или без него, агрегат по окну (Window(Sum("price")), над выражением или с условием _filter=). Выражение, SQL которого не берёт значений, — Pi(), Now(), TransactionNow() и RandomUUID() PostgreSQL — тоже хранит запись. Аргумент None — Case без default, When(then=None), Lag без значения по умолчанию, — часть формы: в тексте SQL это NULL, значения у него нет.

Фильтр по условию Exists(...) и фильтр, значение которого — выражение (F("other"), F("other") + 1, OuterReference("id") в связанном Exists(...)/Subquery(...)), хранят запись: литералы самого выражения подставляются в каждом запросе. Так же хранят запись простое .annotate(x=Value(...)), .annotate(x=Subquery(...)) и сортировка, группировка или DISTINCT ON по невыбранному вычисляемому значению (.annotate(dist=L2Distance("embedding", vector)).order_by("dist")[:10].values_list("id")): значения выражения, заново выведенного там, тоже подставляются.

Exists(...) над запросом .values(), над срезом, с OFFSET или над QuerySet'ом со своим .with_cte(...) хранит запись: запрос, встроенный в условие, входит в ключ, а его значения — включая значения его CTE и OFFSET — подставляются вместе со значениями внешнего запроса. OuterReference(...) через связь (OuterReference("author__name")) тоже хранит запись — JOIN связи во внешнем запросе записывает своё ограничение по умолчанию, как любой другой, — и так же OuterReference(...) по вычисляемому значению внешнего запроса: оно разрешается заново, а его значения подставляются там, где стоит ссылка. Связь «многие ко многим», сравниваемая с внешней строкой (~Q(tags=OuterReference("id")), tags__not=OuterReference("pk") у связи с составным ключом), тоже хранит запись.

with_recursive() хранит запись: строки, с которых начинается обход, — подзапрос, значения которого подставляются при каждом вызове, max_depth — значение, а каждый JOIN обхода записывает своё ограничение по умолчанию.

Запрос .values()/.values_list() с фильтром по оконной функции, обёрнутый в производную таблицу, хранит запись.

FilteredRelation(...) и select_related(Select(relation, extra_condition=...)), связь которого пересекается и в другом месте (.only()/.defer() с её полями, фильтр, .order_by(), .values()/.values_list(), group_by(), вычисляемое значение), хранят запись: условие вплетается в каждый JOIN связи — условие, вплетённое в несколько JOIN (фильтра, сортировки, связи и строки связи «многие-ко-многим»), подставляет свои значения в каждый, а условие, не вплетённое ни в один JOIN (count() без пересечения его связи), не подставляет их никуда. Значения условия — значения самого запроса, поэтому то же верно для запроса, встроенного в другой, — подзапроса, Exists(...), тела CTE, ветки операции над множествами, — даже если он присоединяет FilteredRelation(...) под тем же именем, что и внешний запрос.

Расстояния векторов, полнотекстовый поиск и выражения PostgreSQL хранят запись так же, как встроенные: L2Distance/CosineDistance/InnerProduct (вектор запроса подставляется, преобразованный так, как диалект хранит сравниваемый VectorField), SearchVector, SearchQuery (в том числе запрос из Lexeme), SearchRank и SearchHeadline (текст поиска подставляется, на каждом диалекте), STDistance/STDWithin (точка и радиус подставляются) и функции триграмм.

count() и exists() за границей keyset-пагинации (.after_cursor()/.before_cursor()), exists() после OFFSET и фильтр по строкам операции над множествами (pk__in=a.union(b)) хранят запись: значения границы, OFFSET и значения самого объединения подставляются в каждом запросе.

stream() выполняется по тексту SQL записи так же, как ожидание запроса.

Так же хранят весь свой оператор update() и delete(), чем бы ни выбирались изменяемые строки: фильтрами через связи, фильтром по агрегату (pk IN (SELECT ... GROUP BY ... HAVING ...)), .after_cursor(): в ключ входит вид того, что выбирает строки, и каждое присваиваемое поле с типом его значения или с устройством присваиваемого выражения (F("price") + 1 и F("price") * 1 — два разных вида).

Терм hare.sql, собранный вручную (Field("score") * ValueWrapper(factor)), хранит запись, где бы он ни стоял — в вычисляемом значении, в аргументе функции, в присваивании update(): его текст SQL, где каждое значение — параметр, входит в ключ, а значения, которые он подставляет, подставляются при каждом вызове.

Model.objects.raw(sql, parameters) хранит свою команду для каждого текста SQL, типов параметров и подключения: текст один раз выводится с обозначениями параметров подключения, а следующий вызов подставляет в него свои parameters. Какие колонки результата — поля модели, а какие становятся атрибутами каждого экземпляра, хранится для каждого списка имён колонок. Параметр, который текст держит литералом ("*"), собирает команду каждый раз.

Фильтр через связь хранит запись при любом операторе — author__name="x", author__name__in=[...], author__name__startswith="a", author__books__rating__range=(1, 2), с отрицанием (.exclude(author__name="x")), — а его значение переводится для связанной модели.

Фильтр по вычисляемому значению — .annotate(n=Count("books")).filter(n__gte=2), .annotate(doubled=F("price") * 2).filter(doubled__gt=10), .annotate(has_x=Exists(...)).filter(has_x=True) — хранит запись: значение фильтра и собственные значения выражения привязываются при каждом вызове.

Как запрос описывает свой план

Каждая часть запроса, для которой может храниться план, — фильтр (Q), выражение (F, Value, арифметика, функция или агрегат, Case/When, Window и его оконная функция, Exists, RawSQL), запрос, встроенный в другой (подзапрос, тело CTE, ветка операции над множествами), — описывает себя сама (hare.query.plans.description.Plannable.get_plan_description()). У описания (PlanDescription) две половины: структура — всё, что меняет текст SQL, который строит часть, она входит в ключ плана, — и значения, которые часть подставляет параметрами. Часть описывает свои части их описаниями, поэтому ключ всего запроса — структура его описания.

Описания не пишутся вручную. Класс выражения объявляет, как каждый его атрибут входит в план (plan_parts), а класс запроса — как каждая его настройка входит в ключ (plan_slots: модель, подключение и видимость, фильтры, сортировка, срез, CTE и т. д.); описание каждого класса генерируется из его объявления один раз, когда класс создаётся. Запуск pytest с --verify-plans проверяет, что каждый атрибут описываемого выражения объявлен, а также сравнивает каждый запрос, выполненный по плану, с тем же запросом, построенным полностью.

У каждого значения есть происхождение — атрибут объекта, где оно лежит: значение фильтра — ключ его условия, литерал — атрибут его выражения, срез — его запрос. Первый запрос ключа, пока строится, записывает каждое место, куда текст SQL подставляет значение, вместе с тем, откуда значение взято, и его план подставляет значение следующего запроса в каждое из них — сколько бы раз построение его ни разрешало: условие, которое читают фильтр, сортировка и .values() по одной связи, вложенный QuerySet, встроенный в запрос дважды. Объект, который построение делает заново для каждой копии запроса (копия запроса, встроенного в другой, условие ограничения по умолчанию, вызов фильтра, отложенный без построения), представляет объект, из которого сделан, а условие, которое построение выводит из значения (фильтр через связь, ключ, сравниваемый по колонкам), сохраняет происхождение этого значения.

Ключ — вся структура, сравниваемая на равенство, а не её хэш: два запроса делят план, только когда равно всё, что определяет текст их SQL. Часть, которая не хранит план, описывает себя как None, и так же описывается любой запрос с ней: такой запрос каждый раз строится полностью.

Класс части, который не описывает себя и не объявляет plannable = False, отклоняется уже при определении — см. Своё выражение.

Что считается значением (в ключ не входит и может меняться)

Значение каждого фильтра (хотя его тип в Python в ключ входит: int и float/Decimal для одного фильтра строят разный SQL и никогда не делят запись), значения LIMIT/OFFSET, содержимое списка __in/__not_in (к устройству относится то, есть ли в нём None: None добавляет через OR проверку IS NULL, а пустой список — постоянное условие; список короче длины, с которой диалект передаёт его одним параметром, — 20 значений в PostgreSQL и SQLite, — различается числом значений, отличных от None, а более длинный — ничем: одна запись служит любой длине), обе границы полностью ограниченного __range, значения границы .after_cursor(), константы вычисляемых значений (Value() внутри выражения, константы then=/default= у Case/When, параметры RawSQL, аргументы по умолчанию Coalesce/Concat/NTile/Lag/Lead, значения собственного фильтра Aggregate(_filter=Q(...))) и значения условий select_related(relation, extra_condition=Q(...)) и FilteredRelation(...), max_depth у with_recursive(), значения терма hare.sql, собранного вручную, parameters у .raw(), собственные значения метрики aggregate(), значения, которые присваивает update() (преобразованные так, как их преобразует запись: значение DecimalField округляется до его знаков после запятой), и момент, который он записывает в поля auto_now.

Что по-прежнему идёт без кэша

Несколько видов намеренно исключены — либо потому, что их кэширование грозило бы молча неверным результатом, либо потому, что для них пока нет способа подставить значения. Каждый из них работает правильно, просто без ускорения:

Вид Почему исключён
Lateral(...) и JsonTable(...) У подзапроса или табличной функции, присоединённых под именем, нет описания — их значения стоят в JOIN.
.sample(...) Процент и seed пишутся в FROM, а не подставляются.
Метод QuerySet диалекта, вызванный с аргументом-списком или словарём Такой аргумент не может входить в ключ.
Оператор из register_lookup(), который преобразует значение, без binds_by_rebuild=True Безопасно, только если условие содержит сам результат кодировщика значения — см. ниже.
Терм hare.sql, собранный вручную, который выводится только в своём диалекте или подставляет значение не через ValueWrapper Его описание — текст SQL в нейтральном диалекте, где каждое значение ValueWrapper — параметр.

Ничего из этого не требует изменений в вашем коде — каждый такой запрос просто выполняется без кэша.

JOIN к модели с ограничением по умолчанию — Meta.tenant_field, Meta.soft_delete_field или собственный get_queryset() в Meta.manager у связанной модели или у промежуточной модели through= — идёт быстрым путём: план записывает значения ограничения отдельно от значений самого запроса, а запрос, выполняемый по плану, подставляет ограничение своего контекста (текущего арендатора, то, по чему сейчас фильтрует свой менеджер). Если фильтр ограничения устроен иначе, чем при записи плана, — другое число арендаторов в Tenancy.scope(...), свой менеджер фильтрует по-другому, — запрос собирается полностью.

Границы и время жизни

StatementPlans.plans, StatementPlans.call_signature_plans (ключ цепочки вызовов -> план из plans, по которому она выполняется; сбрасывается, когда plans сбрасывает любой план) и StatementPlans.decode_plans (hare.query.plans.statement.statement_plans) — объекты Cache (hare.core.caching.cache): ограниченные по размеру и разделённые по классам моделей, а не один неограниченный общий словарь. У каждого класса модели своя корзина OrderedDict, работающая по принципу «вытесняется давно не использованное»: попадание перемещает запись в конец (OrderedDict.move_to_end()), а когда в корзине модели становится больше max_size записей, самая старая удаляется (OrderedDict.popitem(last=False)). Предел на модель по умолчанию — 512; его можно изменить для всего процесса переменной окружения HARE_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL (hare.core.caching.constants.DEFAULT_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL) — целым числом от 1 до 1 000 000 (любое другое значение даёт ConfigurationError при импорте hare). Разнообразие видов одной «шумной» модели (например, pk__in= со всё время разной длиной списка) никогда не вытеснит записи другой, не связанной модели, как это сделал бы один общий предел.

Корзины модели хранятся в самой модели (Model._meta), а кэш знает модели со своими корзинами только через WeakSet. Поэтому класс модели, заменённый во время работы (например, через Hare.register_live_models(), повторно регистрирующий ту же таблицу под новым классом), удаляется сборщиком мусора вместе со своими записями, как только на него больше ничто не ссылается.

Отключить кэш нельзя. hare.core.caching.caches.Caches.forget_model_caches([Book, ...]) удаляет всё, что построено для этих моделей: этот кэш, планы чтения строк (StatementPlans.decode_plans), сохранённый SQL сохранения и удаления, обходы связей каскадного удаления и все остальные кэши, построенные для моделей, — так делает сам hare, когда связи модели меняются после первого использования и когда живая модель регистрируется заново или снимается с регистрации. Используйте это в тесте, которому нужен холодный кэш; коду приложения это не нужно.

Каждый из этих кэшей зарегистрирован в Caches (hare.core.caching.caches) там, где он объявлен: кэш значений по ключу — это Cache (регистрирует себя сам; ключ с классом модели попадает в корзину этой модели, любой другой — в собственную корзину кэша, каждая корзина вытесняет давно не использованное), кэш с одним значением на класс модели — это ModelCache (тоже регистрирует себя сам; с depends_on_other_models=True он сбрасывается целиком при изменении любой модели — для значений, описывающих граф связей), а всё остальное, что реализует forget_model(model) / forget_all(), передаётся в Caches.register(). forget_model_caches() и forget_all_caches() (вызывается при каждом изменении реестров — регистрации оператора, типа поля, метода диалекта) проходят по зарегистрированным кэшам; отдельного списка кэшей нигде нет.

Кроме max_size (сколько записей держит корзина), у Cache такие параметры. holds_sql=False — для значений без SQL (сгенерированная модель pydantic, описание ключа фильтра): записи модели тогда остаются, к какому бы построителю запросов модель ни была привязана, и для ещё не привязанной модели. keyed_by_model=False — для ключей, в которых класса модели не бывает: модель в них не ищется. depends_on_other_models=True сбрасывает все записи при изменении любой модели, как у ModelCache. У кэшей с ключами моделей перебор и len(cache) не учитывают SQL-записи другого построителя запросов, как и чтение, которое считает их отсутствующими. model_attribute — атрибут _meta модели, где её обычная корзина (см. ниже) хранится ещё и там, owner_attribute — атрибут, в котором владелец держит своё единственное значение, а dropped_with=<another cache> сбрасывает все записи этого кэша всякий раз, когда тот кэш сбрасывает любую свою.

Там, где обращение должно стоить не дороже словаря, кэш выдаёт обычный словарь, которым по-прежнему владеет сам: Cache.get_model_bucket(model) — словарь, который модель хранит для этого кэша (описания её ключей фильтров и имён сортировки, разобранные пути, раскладки строк); он удаляется вместе с записями модели, поэтому запрашивается при каждом чтении. Cache.get_owner_bucket(owner) — словарь другого объекта (реестр типов и рендереры диалекта держат в нём найденное по базовым классам, класс запроса RequestQuery — свою проверенную декларацию): владелец хранит его в своём cache_buckets, а кэш очищает на месте; Cache.new_shared_bucket() — словарь, принадлежащий тем, кто его держит (набор запросов и его клоны хранят в нём описания ключей, начинающихся с аннотации). Одно значение, которое читается при каждом запросе, остаётся в атрибуте владельца (owner_attribute, Cache.set_owner_value() — набор запросов менеджера); кэш возвращает туда None, когда сбрасывает его. Факт о модели — вычисляемый один раз по её полям и связям — это функция с декоратором ModelCache.fact().

Разделение по подключению и построителю запросов

Ключ включает диалект подключения, на котором выполняется запрос, имя этого подключения и класс построителя запросов, к которому сейчас привязана модель. Два подключения никогда не делят записи, даже если у них один диалект и одна и та же модель указывает на разные по устройству схемы. Запрос, построенный, пока вложенный HareContext привязал те же модели к построителю другого диалекта, никогда не будет отдан под ключом внешнего контекста после выхода из вложенного. SQL, сохранённый для записи, удаления и поиска вида Model.objects.get(), разделяется так же.

Правильность

Кэш хранит настоящее устройство запроса под ключом из ключей фильтров, а не значений, поэтому главное правило: использовать для другого вызова неверное устройство условия или устаревшее либо чужое значение — значит молча получить неверные данные, а не просто упустить ускорение. Отсюда два правила:

  • Оператор из register_lookup() не должен преобразовывать значение. Свой оператор, который вставляет в условие напрямую field.to_db_value(value, model) (или value_encoder(value)), можно кэшировать — при попадании новое значение привязывается тем же путём. Оператор, который вычисляет из значения что-то другое (например, переворачивает строку перед сравнением), кэшировать нельзя — при попадании молча продолжало бы применяться преобразованное значение первого вызова. hare проверяет это по тождеству объектов, прежде чем привязывать значения вида, поэтому небезопасный свой оператор исключается автоматически, а не кэшируется неверно. Оператор, чей FieldLookup объявляет binds_by_rebuild=True, всё же получает план: запрос, выполняемый по записи, заново строит условие из своего значения и подставляет то, что в нём оказалось, — это верно, только если текст SQL зависит от значения лишь через его тип (и длину списка).
  • Всё, что встроено в устройство запроса, а не хранится значением, которое привязывается при попадании, должно входить в ключ вида. Самый наглядный пример: field__year/__month и подобные встраивают имя текущего пояса в своё условие (EXTRACT(... AT TIME ZONE $1)) — в базу оно уходит параметром, но при попадании в кэш заново не привязывается, — поэтому имя пояса входит в ключ вида, и смена пояса во время работы (Timezone.override(), другой init()) не продолжает молча извлекать части в старом, уже сохранённом поясе. Без use_timezone выражение с датой и временем переводит значение из собственного пояса машины, и имя этого пояса входит в ключ так же.

Скорость

Единого официального замера «на сколько быстрее» для кэша в целом нет — его смысл в устройстве: не обходить и не разбирать заново всё дерево фильтров, вычисляемых значений и соединений, не строить заново объекты выражений hare.sql и не выводить SQL при каждом вызове того же вида, а только привязывать значения. Затраты, против которых он направлен, реальны: например, создание обёртки параметра для каждого элемента большого списка заранее стоило около 97% стоимости построения самого списка, поэтому план привязывает аргумент-список одним параметром, а не пересобирает его поэлементно. Но это число описывает одно внутреннее решение, а не общий выигрыш от кэша.