caching_query.py
250 lines
| 8.0 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2010-2024 RhodeCode GmbH | |||
r1 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
r2883 | """caching_query.py | |||
r1 | ||||
r2883 | Represent functions and classes | |||
which allow the usage of Dogpile caching with SQLAlchemy. | ||||
Introduces a query option called FromCache. | ||||
r1 | ||||
r5000 | .. versionchanged:: 1.4 the caching approach has been altered to work | |||
based on a session event. | ||||
r1 | The three new concepts introduced here are: | |||
r5000 | * ORMCache - an extension for an ORM :class:`.Session` | |||
r2883 | retrieves results in/from dogpile.cache. | |||
r1 | * FromCache - a query option that establishes caching | |||
parameters on a Query | ||||
* RelationshipCache - a variant of FromCache which is specific | ||||
to a query invoked during a lazy load. | ||||
The rest of what's here are standard SQLAlchemy and | ||||
r2883 | dogpile.cache constructs. | |||
r1 | ||||
""" | ||||
r2883 | from dogpile.cache.api import NO_VALUE | |||
r1 | ||||
r5000 | from sqlalchemy import event | |||
from sqlalchemy.orm import loading | ||||
from sqlalchemy.orm.interfaces import UserDefinedOption | ||||
DEFAULT_REGION = "sql_cache_short" | ||||
r1 | ||||
r5000 | class ORMCache: | |||
r1 | ||||
r5000 | """An add-on for an ORM :class:`.Session` optionally loads full results | |||
from a dogpile cache region. | ||||
r1 | ||||
r5000 | cache = ORMCache(regions={}) | |||
cache.listen_on_session(Session) | ||||
r1 | ||||
""" | ||||
r5000 | ||||
def __init__(self, regions): | ||||
self.cache_regions = regions or self._get_region() | ||||
self._statement_cache = {} | ||||
@classmethod | ||||
def _get_region(cls): | ||||
r2883 | from rhodecode.lib.rc_cache import region_meta | |||
return region_meta.dogpile_cache_regions | ||||
r1 | ||||
r5000 | def listen_on_session(self, session_factory): | |||
event.listen(session_factory, "do_orm_execute", self._do_orm_execute) | ||||
def _do_orm_execute(self, orm_context): | ||||
for opt in orm_context.user_defined_options: | ||||
if isinstance(opt, RelationshipCache): | ||||
opt = opt._process_orm_context(orm_context) | ||||
if opt is None: | ||||
continue | ||||
if isinstance(opt, FromCache): | ||||
dogpile_region = self.cache_regions[opt.region] | ||||
r1 | ||||
r5085 | if dogpile_region.expiration_time <= 0: | |||
# don't cache 0 time expiration cache | ||||
continue | ||||
r5000 | if opt.cache_key: | |||
our_cache_key = f'SQL_CACHE_{opt.cache_key}' | ||||
else: | ||||
our_cache_key = opt._generate_cache_key( | ||||
orm_context.statement, orm_context.parameters, self | ||||
) | ||||
r1 | ||||
r5000 | if opt.ignore_expiration: | |||
cached_value = dogpile_region.get( | ||||
our_cache_key, | ||||
expiration_time=opt.expiration_time, | ||||
ignore_expiration=opt.ignore_expiration, | ||||
) | ||||
else: | ||||
r2883 | ||||
r5000 | def createfunc(): | |||
return orm_context.invoke_statement().freeze() | ||||
cached_value = dogpile_region.get_or_create( | ||||
our_cache_key, | ||||
createfunc, | ||||
expiration_time=opt.expiration_time, | ||||
) | ||||
r2671 | ||||
r5000 | if cached_value is NO_VALUE: | |||
# keyerror? this is bigger than a keyerror... | ||||
raise KeyError() | ||||
r2671 | ||||
r5000 | orm_result = loading.merge_frozen_result( | |||
orm_context.session, | ||||
orm_context.statement, | ||||
cached_value, | ||||
load=False, | ||||
r2883 | ) | |||
r5000 | return orm_result() | |||
r1 | else: | |||
r5000 | return None | |||
r2883 | ||||
r5000 | def invalidate(self, statement, parameters, opt): | |||
"""Invalidate the cache value represented by a statement.""" | ||||
statement = statement.__clause_element__() | ||||
r1 | ||||
r5000 | dogpile_region = self.cache_regions[opt.region] | |||
r1 | ||||
r5000 | cache_key = opt._generate_cache_key(statement, parameters, self) | |||
r2883 | dogpile_region.delete(cache_key) | |||
r1 | ||||
r5000 | class FromCache(UserDefinedOption): | |||
r1 | """Specifies that a Query should load results from a cache.""" | |||
propagate_to_loaders = False | ||||
r5000 | def __init__( | |||
self, | ||||
region=DEFAULT_REGION, | ||||
cache_key=None, | ||||
expiration_time=None, | ||||
ignore_expiration=False, | ||||
): | ||||
r1 | """Construct a new FromCache. | |||
:param region: the cache region. Should be a | ||||
r5000 | region configured in the dictionary of dogpile | |||
regions. | ||||
r1 | ||||
:param cache_key: optional. A string cache key | ||||
r5000 | that will serve as the key to the query. Use this | |||
if your query has a huge amount of parameters (such | ||||
as when using in_()) which correspond more simply to | ||||
some other identifier. | ||||
r1 | ||||
""" | ||||
self.region = region | ||||
self.cache_key = cache_key | ||||
r5000 | self.expiration_time = expiration_time | |||
self.ignore_expiration = ignore_expiration | ||||
r1 | ||||
r5000 | # this is not needed as of SQLAlchemy 1.4.28; | |||
# UserDefinedOption classes no longer participate in the SQL | ||||
# compilation cache key | ||||
def _gen_cache_key(self, anon_map, bindparams): | ||||
return None | ||||
def _generate_cache_key(self, statement, parameters, orm_cache): | ||||
"""generate a cache key with which to key the results of a statement. | ||||
This leverages the use of the SQL compilation cache key which is | ||||
repurposed as a SQL results key. | ||||
""" | ||||
statement_cache_key = statement._generate_cache_key() | ||||
key = statement_cache_key.to_offline_string( | ||||
orm_cache._statement_cache, statement, parameters | ||||
) + repr(self.cache_key) | ||||
# print("here's our key...%s" % key) | ||||
return key | ||||
r1 | ||||
r5000 | class RelationshipCache(FromCache): | |||
r1 | """Specifies that a Query as called within a "lazy load" | |||
r5000 | should load results from a cache.""" | |||
r1 | ||||
propagate_to_loaders = True | ||||
r5000 | def __init__( | |||
self, | ||||
attribute, | ||||
region=DEFAULT_REGION, | ||||
cache_key=None, | ||||
expiration_time=None, | ||||
ignore_expiration=False, | ||||
): | ||||
r1 | """Construct a new RelationshipCache. | |||
:param attribute: A Class.attribute which | ||||
r5000 | indicates a particular class relationship() whose | |||
lazy loader should be pulled from the cache. | ||||
r1 | ||||
r2883 | :param region: name of the cache region. | |||
:param cache_key: optional. A string cache key | ||||
r5000 | that will serve as the key to the query, bypassing | |||
the usual means of forming a key from the Query itself. | ||||
r2883 | ||||
r1 | """ | |||
self.region = region | ||||
r2883 | self.cache_key = cache_key | |||
r5000 | self.expiration_time = expiration_time | |||
self.ignore_expiration = ignore_expiration | ||||
r1 | self._relationship_options = { | |||
(attribute.property.parent.class_, attribute.property.key): self | ||||
} | ||||
r5000 | def _process_orm_context(self, orm_context): | |||
current_path = orm_context.loader_strategy_path | ||||
r2883 | ||||
r5000 | if current_path: | |||
mapper, prop = current_path[-2:] | ||||
r2883 | key = prop.key | |||
r1 | ||||
for cls in mapper.class_.__mro__: | ||||
if (cls, key) in self._relationship_options: | ||||
r5000 | relationship_option = self._relationship_options[ | |||
(cls, key) | ||||
] | ||||
return relationship_option | ||||
r1 | ||||
def and_(self, option): | ||||
"""Chain another RelationshipCache option to this one. | ||||
While many RelationshipCache objects can be specified on a single | ||||
Query separately, chaining them together allows for a more efficient | ||||
lookup during load. | ||||
""" | ||||
self._relationship_options.update(option._relationship_options) | ||||
return self | ||||