Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/cache.py: 17%
305 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1"""
2This module implements a cache that can be used to serve entire requests or cache the output of any function
3(as long it's result can be stored in datastore). The intended use is to wrap functions that can be called from
4the outside (@exposed) with the @ResponseCache decorator. This will enable the cache provided in this module for that
5function, intercepting all calls to this function and serve a cached response instead of calling the function if
6possible. Authenticated users with "root" access can always bypass this cache by sending the X-Viur-Disable-Cache
7http Header along with their requests. Entities in this cache will expire if
8 - Their TTL is exceeded
9 - They're explicitly removed from the cache by calling :meth:`viur.core.cache.flushCache` using their path
10 - A Datastore entity that has been accessed using db.get() from within the cached function has been modified
11 - The wrapped function has run a query over a kind in which an entity has been added/edited/deleted
13..Warning: As this cache is intended to be used with exposed functions, it will not only store the result of the
14 wrapped function, but will also store and restore the Content-Type http header. This can cause unexpected
15 behaviour if it's used to cache the result of non top-level functions, as calls to these functions now may
16 cause this header to be rewritten.
17"""
19import collections
20import enum
21import inspect
22import logging
23import os
24import sys
25import typing as t
26import zlib
27from datetime import timedelta as td
28from functools import wraps
29from hashlib import sha512
31from viur.core import Method, conf, current, db, errors, utils, tasks, skeleton, bones
32from viur.core.config import ConfigType
33from webob.datetime_utils import serialize_date
35logger = logging.getLogger(__name__)
36if logger.level == logging.NOTSET: 36 ↛ 39line 36 didn't jump to line 39 because the condition on line 36 was always true
37 logger.setLevel(logging.INFO)
39__all__ = [
40 "UserSensitive",
41 "BypassCache",
42 "ResponseCache",
43 "DEFAULT_SETTINGS",
44 "DEFAULT_COMPRESSION",
45 "CACHE_KINDNAME",
46 "flushCache",
47]
49CACHE_KINDNAME: t.Final[str] = "viur-cache"
51MAX_PROPERTY_SIZE: t.Final[int] = 1024 ** 2 - 89
52"""Maximal possible property size in a datastore entity"""
55class UserSensitive(enum.IntEnum):
56 """
57 Signals wherever the output of the wrapped method depends on the current user.
58 """
60 IGNORE = enum.auto()
61 """independent of wherever the user is a guest or known, all will get the same content."""
63 GUEST_ONLY = enum.auto()
64 """cache only for guests, no cache will be performed if the user is logged-in."""
66 BOTH = enum.auto()
67 """cache in two groups, one for guests and one for all users"""
69 INDIVIDUAL = enum.auto()
70 """cache the result of that function for each individual users separately."""
73BypassCache = collections.namedtuple("BypassCache", ["reason"])
74"""Class to signal that the request should not be cached with a reason"""
76_SENTINEL = enum.Enum("_SENTINEL", "sentinel")
77sentinel = _SENTINEL.sentinel # noqa
78"""Sentinel for not provided argument (signal to merge value from default settings)"""
80Args = t.ParamSpec("Args")
81"""type hint for func arguments"""
82Value = t.TypeVar("Value")
83"""type hint for func response (request response)"""
85DEFAULT_COMPRESSION = zlib.Z_DEFAULT_COMPRESSION
86"""Default compression (alias for zlib.Z_DEFAULT_COMPRESSION)"""
89class DefaultSettings(ConfigType):
90 """
91 Singleton settings container type to hold global settings.
93 Instead of repeating argument in every @ResponseCache decorator
94 settings can be set here once.
95 Argument provided directly to @ResponseCache will always have priority.
96 """
98 language_sensitive: bool = False
99 user_sensitive: UserSensitive = UserSensitive.IGNORE
100 max_cache_time: td | None = None
101 compression_level: int = None
102 evaluated_args: list[str] | tuple[str, ...] = tuple()
103 renderer: list[str | t.Type] | tuple[str | t.Type, ...] = None
105 raise_too_large: bool = False
106 """
107 If the response is too large to cache and this option is True, an exception is raised.
108 If it's False, the uncached response is returned to ensure response delivery.
109 """
112DEFAULT_SETTINGS = DefaultSettings(strict_mode=True)
113"""The global instance of DefaultSettings"""
116class ResponseCache(t.Generic[Args, Value]):
117 """
118 Decorator class to cache the result of the reponse.
120 ResponseCache caches:
121 - Normal 200 responses, regardless of the content-type
122 - Including headers changed by the wrapped function
123 - Redirects 3**
125 Parameters can control what and how long it should be cached,
126 see the descriptions in :meth:`__init__`.
128 Example usage:
130 >>> from viur.core import exposed
131 >>> import datetime
132 >>> @exposed
133 >>> @ResponseCache(max_cache_time=datetime.timedelta(days=1))
134 >>> def index(self):
135 >>> return f"This result was cached at {datetime.datetime.now()}"
136 """
138 __slots__ = (
139 "compression_level",
140 "evaluated_args",
141 "language_sensitive",
142 "max_cache_time",
143 "renderer",
144 "urls",
145 "user_sensitive",
146 )
148 REDIRECT_FLAG = "<<REDIRECT>>"
149 """
150 Flag used as content-type to signal that this is a cached redirect instead of a normal response.
151 """
153 def __init__(
154 self,
155 *,
156 urls: list[str] | tuple[str, ...] | None = None,
157 renderer: list[str | t.Type] | tuple[str | t.Type, ...] | None = sentinel,
158 user_sensitive: UserSensitive = sentinel,
159 language_sensitive: bool = sentinel,
160 evaluated_args: list[str] | tuple[str, ...] = sentinel,
161 max_cache_time: td | None = sentinel,
162 compression_level: int = sentinel,
163 ):
164 """
165 Create a ResponseCache instance
167 :param urls:
168 A list of urls for this function, for which the cache should be enabled.
169 A method can have several urls (e.g. /page/view, /pdf/page/view or /pdf/seite/view),
170 and it might should not be cached under all urls (e.g. /vi/page/view).
171 If the parameter is omitted, the URL is ignored for the check and the call is saved in the cache
172 (unless excluded by other parameters), regardless of the path via which the method was called.
173 :param renderer:
174 This parameter can be used to specify render names (such as html, json)
175 under which the result should be cached.
176 The parameter can be used as an alternative to the `url` parameter, but can also be used in addition.
177 :param user_sensitive:
178 Signals wherever the output of the wrapped method depends on the current user.
179 Look at :class:`UserSensitive` for parameter descriptions.
180 :param language_sensitive:
181 If True, signals that the output of the wrapped method should
182 be cached separately for each language (because it's translated).
183 :param evaluated_args:
184 List of argument name having influence to the output generated by that wrapped method.
185 This list *must* be complete! Parameters not named here are ignored!
186 Warning: Double-check this list! F.e. if that function generates a list of entries and
187 you miss the parameter "order" here, it would be impossible to sort the list.
188 It would always have the ordering it had when the cache-entry was created.
189 If the wrapped method use variable positional arguments (*args)
190 or variable keyword arguments (**kwargs) you can include "arg" and/or "kwargs"
191 to this list to accept all variable arguments that are passed by these.
192 If only certain parameters of **kwargs should be considered add the key like it
193 would be a explicit positional or keyword argument.
194 :param max_cache_time:
195 Specifies the maximum time an entry stays in the cache.
196 Note: It's not erased from the database after that time, but it won't be served anymore.
197 If None, the cache stays valid forever (until manually erased by calling flushCache).
198 :param compression_level:
199 Large pages may be too big for the datastore (max. approx. 1 MB, but including meta data).
200 If this parameter is activated, the page is compressed before it is saved in the entity.
201 Possible values for setting the compression level are the numbers 0 - 9.
202 Compression is deactivated with None.
203 See also :param:`DEFAULT_SETTINGS.raise_too_large`.
204 """
205 # Use default values if a argument was not provided
206 if renderer is sentinel:
207 renderer = DEFAULT_SETTINGS.renderer
208 if user_sensitive is sentinel:
209 user_sensitive = DEFAULT_SETTINGS.user_sensitive
210 if language_sensitive is sentinel:
211 language_sensitive = DEFAULT_SETTINGS.language_sensitive
212 if evaluated_args is sentinel:
213 evaluated_args = DEFAULT_SETTINGS.evaluated_args
214 if max_cache_time is sentinel:
215 max_cache_time = DEFAULT_SETTINGS.max_cache_time
216 if compression_level is sentinel:
217 compression_level = DEFAULT_SETTINGS.compression_level
218 self.urls = urls
219 self.renderer: list[str | t.Type] | tuple[str | t.Type, ...] | None = renderer
220 self.user_sensitive: UserSensitive = user_sensitive
221 self.language_sensitive: bool = language_sensitive
222 self.evaluated_args: list[str] | tuple[str, ...] = evaluated_args
223 if max_cache_time is None:
224 self.max_cache_time: None = None
225 else:
226 self.max_cache_time: td = utils.parse.timedelta(max_cache_time)
227 self.compression_level: int | None = compression_level
229 def __call__(this, func: t.Callable[Args, Value]) -> Value:
230 """
231 Does the actual work of wrapping a callable @exposed method
232 and return a internal wrapper.
233 """
235 method = None
236 if isinstance(func, Method):
237 # Wrapping an (exposed) Method; continue with Method._func
238 method = func
239 func = func._func
241 @wraps(func)
242 def wrapper(self, *args: Args.args, **kwargs: Args.kwargs) -> Value:
243 """
244 Wrapper which is called if the route is called
245 and returns a cached response or caches the response (and return it)
246 """
247 current_request = current.request.get()
248 logger.debug(f"Call {func} via {this}")
250 def bypass_response() -> Value:
251 """Call the func, set bybass and no-cache headers and return it"""
252 try:
253 response = func(self, *args, **kwargs)
254 finally:
255 current_request.response.headers["X-Cache-Status"] = "BYPASS"
256 current_request.response.headers["Cache-Control"] = "no-cache"
257 return response
259 if conf.debug.disable_cache or current_request.disableCache:
260 if conf.debug.disable_cache:
261 logger.debug("Caching is disabled by config")
262 return bypass_response()
264 # logger.debug(f"{utils.vars_full(current_request)=}")
266 # How many arguments are part of the way to the function called (and how many are just *args)
267 offset = -len(current_request.args) or len(current_request.path_list)
268 # Get just the path segment before the arguments (the @exposed route)
269 path = "/".join(current_request.path_list[:offset])
270 path = f"/{path.strip('/')}" # normalize /
271 logger.debug(f"{path=}")
273 if this.urls is not None and path not in this.urls:
274 logger.debug(f"{path} is not {this.urls} and should not be cached")
275 return bypass_response()
277 if this.renderer is None:
278 logger.debug(f"Request should be cached on all renderers (not specified)")
279 elif (renderer := getattr(self, "render", "NOT_SET")) is None or renderer == "NOT_SET":
280 logger.error(f"{self}.render is {renderer}, skipping this check")
281 else:
282 is_allowed_renderer = (
283 (isinstance(r, str) and r == self.render.kind) # Renderer kind provided (str)
284 or (not isinstance(r, str) and isinstance(self.render, r)) # Renderer cls provided (type)
285 for r in this.renderer
286 )
287 if not any(is_allowed_renderer):
288 logger.debug(f"{self.render} with {self.render.kind=} should not be cached (only {this.renderer})")
289 return bypass_response()
290 logger.debug(f"{self.render} should be cached")
292 """
293 try:
294 logger.debug(f"{self.seo_language_map=}")
295 except AttributeError as exc:
296 logger.exception(exc)
297 try:
298 logger.debug(f"{getattr(self, func.__name__).seo_language_map=}")
299 except AttributeError as exc:
300 logger.exception(exc)
301 """
302 # TODO: we could add an option to handle them as synonynms ...
304 cache_args = this.get_args(func=func, path=path, args=args, kwargs=kwargs)
305 if isinstance(cache_args, BypassCache):
306 logger.info(f"This request should not be cached ({cache_args=})")
307 return bypass_response()
309 cache_key = this.get_string_from_args(cache_args)
310 logger.debug(f"{cache_key=}")
312 entity = db.get(db.Key(CACHE_KINDNAME, cache_key))
313 cache_status = "MISS"
314 if entity:
315 if not this.max_cache_time or utils.utcNow() <= entity["creationdate"] + this.max_cache_time:
316 # We store it unlimited or the cache is fresh enough
317 logger.debug("This request was served from cache.")
318 for key, value in entity["header"].items():
319 logger.debug(f"Load header {key=} = {value=}")
320 current_request.response.headers[key] = value
321 current_request.response.headers["X-Cache-Status"] = "HIT"
322 if entity["content-type"] == this.REDIRECT_FLAG:
323 raise errors.Redirect(**entity["data"])
324 current_request.response.headers["Content-Type"] = entity["content-type"]
325 current_request.response.headers["Last-Modified"] = serialize_date(entity["creationdate"])
326 current_request.response.headers["X-Cache-Served"] = serialize_date(utils.utcNow())
327 current_request.response.headers["X-Cache-Key"] = str(entity.key.id_or_name) # TODO: tmp
328 if entity["compression_level"] is not None:
329 return zlib.decompress(entity["data"]).decode("utf-8")
330 return entity["data"]
331 logger.debug("Cache is too old")
332 cache_status = "UPDATED"
334 # we will store only additional headers, added in the func call
335 old_headers = list(current_request.response.headers.keys())
337 redirect = None
339 # If we made it this far, the request wasn't cached or too old; we need to rebuild it
340 old_access_log = db.startDataAccessLog()
341 try:
342 uncompressed_body = body = func(self, *args, **kwargs)
343 except errors.Redirect as redirect_exc:
344 redirect = redirect_exc # assign to variable from outer scope
345 logger.info("Got a redirect to cache")
346 content_type = this.REDIRECT_FLAG
347 uncompressed_body = body = {
348 "url": redirect.url,
349 "status": redirect.status,
350 "descr": redirect.descr,
351 }
352 else:
353 content_type = current_request.response.headers["Content-Type"]
354 body_size = uncompressed_size = sys.getsizeof(body)
355 logger.debug(f"{uncompressed_size=}")
357 if this.compression_level is not None:
358 body = zlib.compress(body.encode("utf-8"), this.compression_level)
359 body_size = compressed_size = sys.getsizeof(body)
360 logger.debug(f"{compressed_size=}")
361 logger.info(
362 f"Compression saved {uncompressed_size - compressed_size} bytes"
363 f" ({round((1 - compressed_size / uncompressed_size) * 100.0, 4)} %)"
364 f" ({uncompressed_size} --> {compressed_size})"
365 )
367 if body_size > MAX_PROPERTY_SIZE:
368 # TODO: We should choose a good lower value, we need to store metadata too ...
369 logger.error("This response cannot be caches. It's too large")
370 if this.compression_level is None:
371 logger.error(f"Compression is disabled. Reduce the response size or enable it")
372 else:
373 logger.error(f"Reduce the response size or increase the compression level")
375 current_request.response.headers["X-Cache-Status"] = "TOO_LARGE"
376 if DEFAULT_SETTINGS.raise_too_large:
377 raise errors.InternalServerError("Response too large for caching")
378 return uncompressed_body
379 finally:
380 accessed_entries = db.endDataAccessLog(old_access_log)
382 entity = db.Entity(db.Key(CACHE_KINDNAME, cache_key))
383 entity["data"] = body
384 entity["creationdate"] = utils.utcNow()
385 entity["path"] = path
386 entity["url"] = f"/{'/'.join(current_request.path_list)}"
387 entity["content-type"] = content_type
388 entity["accessedEntries"] = list(accessed_entries)
389 entity["compression_level"] = this.compression_level
390 headers = db.Entity()
392 for key, value in current_request.response.headers.items():
393 if (key.lower().startswith("x-") and key not in old_headers or key.lower() in {"cache-control"}):
394 logger.debug(f"Save header {key} = {value}")
395 headers[key] = value
396 else:
397 logger.debug(f"Ignore header {key} = {value}")
398 entity.exclude_from_indexes.add("data")
399 entity.exclude_from_indexes.add("header")
400 entity["header"] = headers
401 entity = db.fix_unindexable_properties(entity)
402 db.Put(entity)
404 logger.debug("This request was a cache-miss. Cache has been updated.")
405 current_request.response.headers["X-Cache-Status"] = cache_status
406 current_request.response.headers["Last-Modified"] = serialize_date(entity["creationdate"])
407 current_request.response.headers["X-Cache-Served"] = serialize_date(utils.utcNow())
408 current_request.response.headers["X-Cache-Key"] = str(entity.key.id_or_name) # TODO: tmp
410 if content_type == this.REDIRECT_FLAG:
411 raise redirect
413 if this.compression_level is not None:
414 # Return not the compressed body for the response
415 return uncompressed_body
416 return body
418 if method is None:
419 return wrapper
420 else:
421 method._func = wrapper
422 return method
424 def get_args(
425 self,
426 func: t.Callable,
427 path: str,
428 args: tuple,
429 kwargs: dict,
430 ) -> dict[str, t.Any] | BypassCache:
431 """
432 Create a argument dict to build the cache key.
434 In addition to the arguments to be considered (evaluated_args) of the request,
435 parameters are also formed from the other options of this class.
436 """
437 logger.debug(f"{args=} // {kwargs=} // {self.evaluated_args=} // {path=}")
439 signature = inspect.signature(func)
440 logger.debug(f"{signature=}")
441 logger.debug(f"{signature.parameters=}")
443 remaining_kwargs = kwargs.copy()
444 res = {}
446 for i, param in enumerate(signature.parameters.values()):
447 if i == 0 and param.name == "self":
448 continue
449 # logger.debug(f"{i=} // {param.name=} // {param=} // {utils.vars_full(param)=}")
450 if param.name not in self.evaluated_args:
451 logger.debug(f"Ignoring {param=} (not in evaluated_args)")
452 elif len(args) >= i and param.kind in {param.POSITIONAL_ONLY,
453 param.POSITIONAL_OR_KEYWORD} and param.name in self.evaluated_args:
454 res[param.name] = args[i - 1]
455 elif param.kind == param.VAR_POSITIONAL and param.name in self.evaluated_args:
456 # *VAR_POSITIONAL must be always the last parameter before kwarg only parameters,
457 # therefore we can consume the entire remaining args
458 res[param.name] = args[i - 1:]
459 elif param.kind == param.VAR_KEYWORD and param.name in self.evaluated_args:
460 # **VAR_KEYWORDS must be always the last parameter,
461 # therefore we can consume the entire remaining kwargs
462 res |= remaining_kwargs
463 remaining_kwargs.clear()
464 elif param.name in remaining_kwargs and param.name in self.evaluated_args:
465 res[param.name] = remaining_kwargs.pop(param.name)
466 elif param.default is not param.empty and param.name in self.evaluated_args:
467 res[param.name] = param.default
468 else:
469 # This case should never occur, but never say never ...
470 logger.debug(f"Ignoring {param=}")
472 # Last, merge remaining_kwargs in (passed as **VAR_KEYWORDS),
473 # in this case, only certain and not all **VAR_KEYWORDS should be included.
474 for key, value in remaining_kwargs.items():
475 if key in self.evaluated_args:
476 if key in res:
477 raise ValueError(f"Got duplicate {value=} for {key=}")
478 res[key] = value
479 else:
480 logger.debug(f"Ignore {key=} : {value=} from remaining_kwargs")
482 if self.user_sensitive != UserSensitive.IGNORE:
483 user = current.user.get()
484 if self.user_sensitive == UserSensitive.GUEST_ONLY and user:
485 # We don't cache requests for each user separately
486 return BypassCache("Cache is only for guests enabled")
487 elif self.user_sensitive == UserSensitive.BOTH:
488 res["__user"] = "__ISUSER" if user else None
489 elif self.user_sensitive == UserSensitive.INDIVIDUAL:
490 res["__user"] = user["key"] if user else None
491 elif self.user_sensitive == UserSensitive.GUEST_ONLY:
492 pass # We don't need to store, that we're a guest.
493 else:
494 raise ValueError(f"Invalid value {self.user_sensitive=}")
496 if self.language_sensitive:
497 res["__lang"] = current.language.get()
499 if conf.cache_environment_key:
500 try:
501 res["__cache_environment"] = conf.cache_environment_key()
502 except RuntimeError as exc:
503 logger.warning("Raising RuntimeError to bypass the cache is deprecated. "
504 "Please return a ByPassCache instance instead")
505 res["__cache_environment"] = BypassCache(str(exc))
506 if isinstance(res["__cache_environment"], BypassCache):
507 return res["__cache_environment"]
509 res["__path"] = path # Different path might have different output (html,xml,..)
511 logger.debug(f"{conf.instance.app_version=}")
512 if conf.instance.is_dev_server:
513 res["__app_version"] = f'dev_server_{os.getenv("USER", "")}'
514 else:
515 res["__app_version"] = conf.instance.app_version
517 res["__template_style"] = current.request.get().template_style
518 return res
520 def get_string_from_args(self, args: dict[str, t.Any] | BypassCache) -> str | BypassCache:
521 """Create a string key for the cache entity
523 The parameters are sorted by key, and return as sha512 hash.
525 :param args: The result of :meth:`get_args`
526 """
527 args = utils.freeze_dict(args)
528 logger.debug(f"{args=}")
529 return sha512(str(args).encode("utf-8")).hexdigest()
531 def __repr__(self) -> str:
532 """Representation of this class"""
533 values = ", ".join(f"{key}={getattr(self, key)!r}" for key in self.__slots__)
534 return f"<{type(self).__qualname__} with {values}>"
537@tasks.CallDeferred
538def flushCache(prefix: str = None, key: db.Key | None = None, kind: str | None = None):
539 """
540 Flushes the cache. Its possible the flush only a part of the cache by specifying
541 the path-prefix. The path is equal to the url that caused it to be cached (eg /page/view) and must be one
542 listed in the 'url' param of :class:`ResponseCache`.
544 :param prefix: Path or prefix that should be flushed.
545 :param key: Flush all cache entries which may contain this key. Also flushes entries
546 which executed a query over that kind.
547 :param kind: Flush all cache entries which executed a query over that kind.
549 Examples:
550 - "/" would flush the main page (and only that),
551 - "/*" everything from the cache, "/page/*" everything from the page-module (default render),
552 - and "/page/view/*" only that specific subset of the page-module.
553 """
554 if prefix is None and key is None and kind is None:
555 prefix = "/*"
557 if prefix is not None:
558 items = db.Query(CACHE_KINDNAME).filter("path =", prefix.rstrip("*")).iter()
559 for item in items:
560 db.delete(item)
561 if prefix.endswith("*"):
562 items = db.Query(CACHE_KINDNAME) \
563 .filter("path >", prefix.rstrip("*")) \
564 .filter("path <", prefix.rstrip("*") + u"\ufffd") \
565 .iter()
566 for item in items:
567 db.delete(item)
568 logging.debug(f"Flushing cache succeeded. Everything matching {prefix=} is gone.")
570 if key is not None:
571 items = db.Query(CACHE_KINDNAME).filter("accessedEntries =", key).iter()
573 for item in items:
574 logging.info(f"""Deleted cache entry {item["path"]!r}""")
575 db.delete(item.key)
577 if kind is None and not isinstance(key, db.Key):
578 key = db.Key.from_legacy_urlsafe(key) # hopefully is a string
579 kind = key.kind
581 if kind is not None:
582 items = db.Query(CACHE_KINDNAME).filter("accessedEntries =", kind).iter()
583 for item in items:
584 logging.info(f"""Deleted cache entry {item["path"]!r}""")
585 db.delete(item.key)
588@tasks.CallableTask
589class FlushCacheTask(tasks.CallableTaskBase):
590 key = "FlushCacheTask"
591 name = "Clear Response Cache"
592 descr = "Clears the Response-Cache (completely, or filtered, either by prefix or by kind)."
594 def canCall(self):
595 user = current.user.get()
596 return user and "root" in user["access"]
598 class dataSkel(skeleton.RelSkel):
599 prefix = bones.RawBone(
600 descr="Prefix",
601 params={
602 "tooltip": "Path-Prefix (e.g. '/' oder '/page/*'; empty = all)",
603 },
604 )
606 kind = bones.SelectBone(
607 descr="Kind",
608 values=skeleton.listKnownSkeletons,
609 params={
610 "tooltip": "Kind, which cache will be cleared",
611 },
612 )
614 def execute(self, prefix, kind):
615 flushCache(prefix=prefix or None, kind=kind or None)