##// END OF EJS Templates
caches: use new sqlalchemy 1.4 caching query approach
super-admin -
r5000:cd6bb3a6 default
parent child Browse files
Show More
@@ -24,266 +24,217 b' Represent functions and classes'
24 24 which allow the usage of Dogpile caching with SQLAlchemy.
25 25 Introduces a query option called FromCache.
26 26
27 .. versionchanged:: 1.4 the caching approach has been altered to work
28 based on a session event.
29
30
27 31 The three new concepts introduced here are:
28 32
29 * CachingQuery - a Query subclass that caches and
33 * ORMCache - an extension for an ORM :class:`.Session`
30 34 retrieves results in/from dogpile.cache.
31 35 * FromCache - a query option that establishes caching
32 36 parameters on a Query
33 37 * RelationshipCache - a variant of FromCache which is specific
34 38 to a query invoked during a lazy load.
35 * _params_from_query - extracts value parameters from
36 a Query.
37 39
38 40 The rest of what's here are standard SQLAlchemy and
39 41 dogpile.cache constructs.
40 42
41 43 """
42 from sqlalchemy.orm.interfaces import MapperOption
43 from sqlalchemy.orm.query import Query
44 from sqlalchemy.sql import visitors
45 44 from dogpile.cache.api import NO_VALUE
46 45
47 from rhodecode.lib.utils2 import safe_str
46 from sqlalchemy import event
47 from sqlalchemy.orm import loading
48 from sqlalchemy.orm.interfaces import UserDefinedOption
49
50
51 DEFAULT_REGION = "sql_cache_short"
48 52
49 53
50 class CachingQuery(Query):
51 """A Query subclass which optionally loads full results from a dogpile
52 cache region.
54 class ORMCache:
53 55
54 The CachingQuery optionally stores additional state that allows it to consult
55 a dogpile.cache cache before accessing the database, in the form
56 of a FromCache or RelationshipCache object. Each of these objects
57 refer to the name of a :class:`dogpile.cache.Region` that's been configured
58 and stored in a lookup dictionary. When such an object has associated
59 itself with the CachingQuery, the corresponding :class:`dogpile.cache.Region`
60 is used to locate a cached result. If none is present, then the
61 Query is invoked normally, the results being cached.
56 """An add-on for an ORM :class:`.Session` optionally loads full results
57 from a dogpile cache region.
62 58
63 The FromCache and RelationshipCache mapper options below represent
64 the "public" method of configuring this state upon the CachingQuery.
59 cache = ORMCache(regions={})
60 cache.listen_on_session(Session)
65 61
66 62 """
67 def _get_region(self):
63
64 def __init__(self, regions):
65 self.cache_regions = regions or self._get_region()
66 self._statement_cache = {}
67
68 @classmethod
69 def _get_region(cls):
68 70 from rhodecode.lib.rc_cache import region_meta
69 71 return region_meta.dogpile_cache_regions
70 72
71 def __init__(self, regions, *args, **kw):
72 self.cache_regions = regions or self._get_region()
73 Query.__init__(self, *args, **kw)
73 def listen_on_session(self, session_factory):
74 event.listen(session_factory, "do_orm_execute", self._do_orm_execute)
75
76 def _do_orm_execute(self, orm_context):
74 77
75 def __iter__(self):
76 """override __iter__ to pull results from dogpile
77 if particular attributes have been configured.
78 for opt in orm_context.user_defined_options:
79 if isinstance(opt, RelationshipCache):
80 opt = opt._process_orm_context(orm_context)
81 if opt is None:
82 continue
83
84 if isinstance(opt, FromCache):
85 dogpile_region = self.cache_regions[opt.region]
78 86
79 Note that this approach does *not* detach the loaded objects from
80 the current session. If the cache backend is an in-process cache
81 (like "memory") and lives beyond the scope of the current session's
82 transaction, those objects may be expired. The method here can be
83 modified to first expunge() each loaded item from the current
84 session before returning the list of items, so that the items
85 in the cache are not the same ones in the current Session.
87 if opt.cache_key:
88 our_cache_key = f'SQL_CACHE_{opt.cache_key}'
89 else:
90 our_cache_key = opt._generate_cache_key(
91 orm_context.statement, orm_context.parameters, self
92 )
86 93
87 """
88 super_ = super(CachingQuery, self)
89
90 if hasattr(self, '_cache_region'):
91 return self.get_value(createfunc=lambda: list(super_.__iter__()))
92 else:
93 return super_.__iter__()
94
95 def _execute_and_instances(self, context):
96 """override _execute_and_instances to pull results from dogpile
97 if the query is invoked directly from an external context.
94 if opt.ignore_expiration:
95 cached_value = dogpile_region.get(
96 our_cache_key,
97 expiration_time=opt.expiration_time,
98 ignore_expiration=opt.ignore_expiration,
99 )
100 else:
98 101
99 This method is necessary in order to maintain compatibility
100 with the "baked query" system now used by default in some
101 relationship loader scenarios. Note also the
102 RelationshipCache._generate_cache_key method which enables
103 the baked query to be used within lazy loads.
102 def createfunc():
103 return orm_context.invoke_statement().freeze()
104
105 cached_value = dogpile_region.get_or_create(
106 our_cache_key,
107 createfunc,
108 expiration_time=opt.expiration_time,
109 )
104 110
105 .. versionadded:: 1.2.7
106 """
107 super_ = super(CachingQuery, self)
111 if cached_value is NO_VALUE:
112 # keyerror? this is bigger than a keyerror...
113 raise KeyError()
108 114
109 if context.query is not self and hasattr(self, '_cache_region'):
110 # special logic called when the Query._execute_and_instances()
111 # method is called directly from the baked query
112 return self.get_value(
113 createfunc=lambda: list(
114 super_._execute_and_instances(context)
115 orm_result = loading.merge_frozen_result(
116 orm_context.session,
117 orm_context.statement,
118 cached_value,
119 load=False,
115 120 )
116 )
121 return orm_result()
122
117 123 else:
118 return super_._execute_and_instances(context)
124 return None
119 125
120 def _get_cache_plus_key(self):
121 """Return a cache region plus key."""
122 dogpile_region = self.cache_regions[self._cache_region.region]
123 if self._cache_region.cache_key:
124 key = self._cache_region.cache_key
125 else:
126 key = _key_from_query(self)
127 return dogpile_region, key
126 def invalidate(self, statement, parameters, opt):
127 """Invalidate the cache value represented by a statement."""
128
129 statement = statement.__clause_element__()
128 130
129 def invalidate(self):
130 """Invalidate the cache value represented by this Query."""
131 dogpile_region = self.cache_regions[opt.region]
131 132
132 dogpile_region, cache_key = self._get_cache_plus_key()
133 cache_key = opt._generate_cache_key(statement, parameters, self)
134
133 135 dogpile_region.delete(cache_key)
134 136
135 def get_value(self, merge=True, createfunc=None,
136 expiration_time=None, ignore_expiration=False):
137 """Return the value from the cache for this query.
138 137
139 Raise KeyError if no value present and no
140 createfunc specified.
141
142 """
143 dogpile_region, cache_key = self._get_cache_plus_key()
144
145 # ignore_expiration means, if the value is in the cache
146 # but is expired, return it anyway. This doesn't make sense
147 # with createfunc, which says, if the value is expired, generate
148 # a new value.
149 assert not ignore_expiration or not createfunc, \
150 "Can't ignore expiration and also provide createfunc"
151
152 if ignore_expiration or not createfunc:
153 cached_value = dogpile_region.get(cache_key,
154 expiration_time=expiration_time,
155 ignore_expiration=ignore_expiration)
156 else:
157 cached_value = dogpile_region.get_or_create(
158 cache_key,
159 createfunc,
160 expiration_time=expiration_time
161 )
162 if cached_value is NO_VALUE:
163 raise KeyError(cache_key)
164 if merge:
165 cached_value = self.merge_result(cached_value, load=False)
166 return cached_value
167
168 def set_value(self, value):
169 """Set the value in the cache for this query."""
170
171 dogpile_region, cache_key = self._get_cache_plus_key()
172 dogpile_region.set(cache_key, value)
173
174
175 def query_callable(regions=None, query_cls=CachingQuery):
176 def query(*arg, **kw):
177 return query_cls(regions, *arg, **kw)
178 return query
179
180
181 def _key_from_query(query, qualifier=None):
182 """Given a Query, create a cache key.
183
184 There are many approaches to this; here we use the simplest,
185 which is to create an md5 hash of the text of the SQL statement,
186 combined with stringified versions of all the bound parameters
187 within it. There's a bit of a performance hit with
188 compiling out "query.statement" here; other approaches include
189 setting up an explicit cache key with a particular Query,
190 then combining that with the bound parameter values.
191
192 """
193
194 stmt = query.with_labels().statement
195 compiled = stmt.compile()
196 params = compiled.params
197
198 # here we return the key as a long string. our "key mangler"
199 # set up with the region will boil it down to an md5.
200 return " ".join(
201 [safe_str(compiled)] +
202 [safe_str(params[k]) for k in sorted(params)])
203
204
205 class FromCache(MapperOption):
138 class FromCache(UserDefinedOption):
206 139 """Specifies that a Query should load results from a cache."""
207 140
208 141 propagate_to_loaders = False
209 142
210 def __init__(self, region="sql_cache_short", cache_key=None):
143 def __init__(
144 self,
145 region=DEFAULT_REGION,
146 cache_key=None,
147 expiration_time=None,
148 ignore_expiration=False,
149 ):
211 150 """Construct a new FromCache.
212 151
213 152 :param region: the cache region. Should be a
214 region configured in the dictionary of dogpile
215 regions.
153 region configured in the dictionary of dogpile
154 regions.
216 155
217 156 :param cache_key: optional. A string cache key
218 that will serve as the key to the query. Use this
219 if your query has a huge amount of parameters (such
220 as when using in_()) which correspond more simply to
221 some other identifier.
157 that will serve as the key to the query. Use this
158 if your query has a huge amount of parameters (such
159 as when using in_()) which correspond more simply to
160 some other identifier.
222 161
223 162 """
224 163 self.region = region
225 164 self.cache_key = cache_key
165 self.expiration_time = expiration_time
166 self.ignore_expiration = ignore_expiration
226 167
227 def process_query(self, query):
228 """Process a Query during normal loading operation."""
229 query._cache_region = self
168 # this is not needed as of SQLAlchemy 1.4.28;
169 # UserDefinedOption classes no longer participate in the SQL
170 # compilation cache key
171 def _gen_cache_key(self, anon_map, bindparams):
172 return None
173
174 def _generate_cache_key(self, statement, parameters, orm_cache):
175 """generate a cache key with which to key the results of a statement.
176
177 This leverages the use of the SQL compilation cache key which is
178 repurposed as a SQL results key.
179
180 """
181 statement_cache_key = statement._generate_cache_key()
182
183 key = statement_cache_key.to_offline_string(
184 orm_cache._statement_cache, statement, parameters
185 ) + repr(self.cache_key)
186 # print("here's our key...%s" % key)
187 return key
230 188
231 189
232 class RelationshipCache(MapperOption):
190 class RelationshipCache(FromCache):
233 191 """Specifies that a Query as called within a "lazy load"
234 should load results from a cache."""
192 should load results from a cache."""
235 193
236 194 propagate_to_loaders = True
237 195
238 def __init__(self, attribute, region="sql_cache_short", cache_key=None):
196 def __init__(
197 self,
198 attribute,
199 region=DEFAULT_REGION,
200 cache_key=None,
201 expiration_time=None,
202 ignore_expiration=False,
203 ):
239 204 """Construct a new RelationshipCache.
240 205
241 206 :param attribute: A Class.attribute which
242 indicates a particular class relationship() whose
243 lazy loader should be pulled from the cache.
207 indicates a particular class relationship() whose
208 lazy loader should be pulled from the cache.
244 209
245 210 :param region: name of the cache region.
246 211
247 212 :param cache_key: optional. A string cache key
248 that will serve as the key to the query, bypassing
249 the usual means of forming a key from the Query itself.
213 that will serve as the key to the query, bypassing
214 the usual means of forming a key from the Query itself.
250 215
251 216 """
252 217 self.region = region
253 218 self.cache_key = cache_key
219 self.expiration_time = expiration_time
220 self.ignore_expiration = ignore_expiration
254 221 self._relationship_options = {
255 222 (attribute.property.parent.class_, attribute.property.key): self
256 223 }
257 224
258 def _generate_cache_key(self, path):
259 """Indicate to the lazy-loader strategy that a "baked" query
260 may be used by returning ``None``.
261
262 If this method is omitted, the default implementation of
263 :class:`.MapperOption._generate_cache_key` takes place, which
264 returns ``False`` to disable the "baked" query from being used.
265
266 .. versionadded:: 1.2.7
225 def _process_orm_context(self, orm_context):
226 current_path = orm_context.loader_strategy_path
267 227
268 """
269 return None
270
271 def process_query_conditionally(self, query):
272 """Process a Query that is used within a lazy loader.
273
274 (the process_query_conditionally() method is a SQLAlchemy
275 hook invoked only within lazyload.)
276
277 """
278 if query._current_path:
279 mapper, prop = query._current_path[-2:]
228 if current_path:
229 mapper, prop = current_path[-2:]
280 230 key = prop.key
281 231
282 232 for cls in mapper.class_.__mro__:
283 233 if (cls, key) in self._relationship_options:
284 relationship_option = self._relationship_options[(cls, key)]
285 query._cache_region = relationship_option
286 break
234 relationship_option = self._relationship_options[
235 (cls, key)
236 ]
237 return relationship_option
287 238
288 239 def and_(self, option):
289 240 """Chain another RelationshipCache option to this one.
@@ -45,7 +45,8 b' from . import region_meta'
45 45 from .utils import (
46 46 get_default_cache_settings, backend_key_generator, get_or_create_region,
47 47 clear_cache_namespace, make_region, InvalidationContext,
48 FreshRegionCache, ActiveRegionCache)
48 FreshRegionCache, ActiveRegionCache
49 )
49 50
50 51
51 52 FILE_TREE_CACHE_VER = 'v4'
@@ -30,7 +30,6 b' import rhodecode'
30 30 from rhodecode.lib.hash_utils import sha1
31 31 from rhodecode.lib.type_utils import str2bool
32 32 from rhodecode.lib.str_utils import safe_bytes
33 from rhodecode.model.db import Session, CacheKey, IntegrityError
34 33
35 34 from rhodecode.lib.rc_cache import cache_key_meta
36 35 from rhodecode.lib.rc_cache import region_meta
@@ -303,6 +302,8 b' class InvalidationContext(object):'
303 302 self.compute_time = 0
304 303
305 304 def get_or_create_cache_obj(self, cache_type, invalidation_namespace=''):
305 from rhodecode.model.db import CacheKey
306
306 307 invalidation_namespace = invalidation_namespace or self.invalidation_namespace
307 308 # fetch all cache keys for this namespace and convert them to a map to find if we
308 309 # have specific cache_key object registered. We do this because we want to have
@@ -347,6 +348,8 b' class InvalidationContext(object):'
347 348 return FreshRegionCache(context=self, cache_data=cache_data)
348 349
349 350 def __exit__(self, exc_type, exc_val, exc_tb):
351 from rhodecode.model.db import Session, IntegrityError
352
350 353 # save compute time
351 354 self.compute_time = time.time() - self._start_time
352 355
@@ -25,7 +25,8 b' SQLAlchemy Metadata and Session object'
25 25 from sqlalchemy.orm import declarative_base
26 26 from sqlalchemy.orm import scoped_session, sessionmaker
27 27 from sqlalchemy.orm import Session as SASession
28 from rhodecode.lib import caching_query
28 from rhodecode.lib.caching_query import ORMCache
29
29 30
30 31 __all__ = ['Base', 'Session', 'raw_query_executor']
31 32
@@ -36,11 +37,15 b' from rhodecode.lib import caching_query'
36 37 # .options(FromCache("sqlalchemy_cache_type", "cachekey"))
37 38 Session = scoped_session(
38 39 sessionmaker(
39 query_cls=caching_query.query_callable(),
40 40 expire_on_commit=True,
41 41 )
42 42 )
43 43
44 # pass empty regions so we can fetch it on-demand inside ORMCache
45 cache = ORMCache(regions={})
46 cache.listen_on_session(Session)
47
48
44 49 # The declarative Base
45 50 Base = declarative_base()
46 51
General Comments 0
You need to be logged in to leave comments. Login now