2025-01-10 00:57:01 +00:00
|
|
|
"""Utilities for caching data in memcache."""
|
|
|
|
import functools
|
|
|
|
import logging
|
2025-01-10 02:36:26 +00:00
|
|
|
import os
|
2025-01-10 00:57:01 +00:00
|
|
|
|
|
|
|
from google.cloud.ndb.global_cache import _InProcessGlobalCache, MemcacheCache
|
|
|
|
from oauth_dropins.webutil import appengine_info
|
|
|
|
|
2025-01-10 02:36:26 +00:00
|
|
|
from pymemcache.client.base import PooledClient
|
2025-01-10 00:57:01 +00:00
|
|
|
from pymemcache.serde import PickleSerde
|
|
|
|
from pymemcache.test.utils import MockMemcacheClient
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
# https://github.com/memcached/memcached/wiki/Commands#standard-protocol
|
|
|
|
KEY_MAX_LEN = 250
|
|
|
|
|
2025-01-15 16:57:40 +00:00
|
|
|
MEMOIZE_VERSION = 2
|
|
|
|
|
2025-01-10 00:57:01 +00:00
|
|
|
|
|
|
|
if appengine_info.DEBUG or appengine_info.LOCAL_SERVER:
|
|
|
|
logger.info('Using in memory mock memcache')
|
|
|
|
memcache = MockMemcacheClient(allow_unicode_keys=True)
|
|
|
|
pickle_memcache = MockMemcacheClient(allow_unicode_keys=True, serde=PickleSerde())
|
|
|
|
global_cache = _InProcessGlobalCache()
|
|
|
|
else:
|
|
|
|
logger.info('Using production Memorystore memcache')
|
2025-01-10 02:36:26 +00:00
|
|
|
memcache = PooledClient(os.environ['MEMCACHE_HOST'], allow_unicode_keys=True,
|
|
|
|
timeout=10, connect_timeout=10) # seconds
|
|
|
|
pickle_memcache = PooledClient(os.environ['MEMCACHE_HOST'],
|
|
|
|
serde=PickleSerde(), allow_unicode_keys=True,
|
|
|
|
timeout=10, connect_timeout=10) # seconds
|
2025-01-10 00:57:01 +00:00
|
|
|
global_cache = MemcacheCache(memcache)
|
|
|
|
|
|
|
|
|
|
|
|
def key(key):
|
|
|
|
"""Preprocesses a memcache key. Right now just truncates it to 250 chars.
|
|
|
|
|
|
|
|
https://pymemcache.readthedocs.io/en/latest/apidoc/pymemcache.client.base.html
|
|
|
|
https://github.com/memcached/memcached/wiki/Commands#standard-protocol
|
|
|
|
|
|
|
|
TODO: truncate to 250 *UTF-8* chars, to handle Unicode chars in URLs. Related:
|
|
|
|
pymemcache Client's allow_unicode_keys constructor kwarg.
|
2025-03-09 21:16:23 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
key (str)
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
bytes:
|
2025-01-10 00:57:01 +00:00
|
|
|
"""
|
2025-01-14 20:53:55 +00:00
|
|
|
assert isinstance(key, str), repr(key)
|
2025-03-09 21:16:23 +00:00
|
|
|
return key.replace(' ', '%20').encode()[:KEY_MAX_LEN]
|
2025-01-10 00:57:01 +00:00
|
|
|
|
|
|
|
|
2025-01-15 16:57:40 +00:00
|
|
|
def memoize_key(fn, *args, _version=MEMOIZE_VERSION, **kwargs):
|
|
|
|
return key(f'{fn.__qualname__}-{_version}-{repr(args)}-{repr(kwargs)}')
|
2025-01-10 00:57:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
NONE = () # empty tuple
|
|
|
|
|
2025-01-15 16:57:40 +00:00
|
|
|
def memoize(expire=None, key=None, write=True, version=MEMOIZE_VERSION):
|
2025-01-10 00:57:01 +00:00
|
|
|
"""Memoize function decorator that stores the cached value in memcache.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
expire (timedelta): optional, expiration
|
2025-01-30 19:20:55 +00:00
|
|
|
key (callable): function that takes the function's ``(*args, **kwargs)``
|
|
|
|
and returns the cache key to use. If it returns None, memcache won't be
|
|
|
|
used.
|
2025-01-15 00:31:49 +00:00
|
|
|
write (bool or callable): whether to write to memcache. If this is a
|
2025-01-30 19:20:55 +00:00
|
|
|
callable, it will be called with the function's ``(*args, **kwargs)``
|
|
|
|
and should return True or False.
|
2025-01-15 16:57:40 +00:00
|
|
|
version (int): overrides our default version number in the memcache key.
|
|
|
|
Bumping this version can have the same effect as clearing the cache for
|
|
|
|
just the affected function.
|
2025-01-10 00:57:01 +00:00
|
|
|
"""
|
|
|
|
if expire:
|
|
|
|
expire = int(expire.total_seconds())
|
|
|
|
|
|
|
|
def decorator(fn):
|
|
|
|
@functools.wraps(fn)
|
|
|
|
def wrapped(*args, **kwargs):
|
2025-01-14 20:53:55 +00:00
|
|
|
cache_key = None
|
2025-01-10 00:57:01 +00:00
|
|
|
if key:
|
2025-01-14 20:53:55 +00:00
|
|
|
key_val = key(*args, **kwargs)
|
|
|
|
if key_val:
|
2025-01-15 16:57:40 +00:00
|
|
|
cache_key = memoize_key(fn, key_val, _version=version)
|
2025-01-10 00:57:01 +00:00
|
|
|
else:
|
2025-01-15 16:57:40 +00:00
|
|
|
cache_key = memoize_key(fn, *args, _version=version, **kwargs)
|
2025-01-10 00:57:01 +00:00
|
|
|
|
2025-01-14 20:53:55 +00:00
|
|
|
if cache_key:
|
|
|
|
val = pickle_memcache.get(cache_key)
|
|
|
|
if val is not None:
|
2025-01-15 00:31:49 +00:00
|
|
|
logger.debug(f'cache hit {cache_key} {repr(val)[:100]}')
|
2025-01-14 20:53:55 +00:00
|
|
|
return None if val == NONE else val
|
|
|
|
else:
|
|
|
|
logger.debug(f'cache miss {cache_key}')
|
2025-01-10 00:57:01 +00:00
|
|
|
|
|
|
|
val = fn(*args, **kwargs)
|
2025-01-14 20:53:55 +00:00
|
|
|
|
|
|
|
if cache_key:
|
2025-01-15 00:31:49 +00:00
|
|
|
write_cache = (write if isinstance(write, bool)
|
|
|
|
else write(*args, **kwargs))
|
|
|
|
if write_cache:
|
|
|
|
logger.debug(f'cache set {cache_key} {repr(val)[:100]}')
|
|
|
|
pickle_memcache.set(cache_key, NONE if val is None else val,
|
|
|
|
expire=expire)
|
2025-01-14 20:53:55 +00:00
|
|
|
|
2025-01-10 00:57:01 +00:00
|
|
|
return val
|
|
|
|
|
|
|
|
return wrapped
|
|
|
|
|
|
|
|
return decorator
|
2025-04-18 22:55:50 +00:00
|
|
|
|
|
|
|
|
|
|
|
###########################################
|
|
|
|
|
|
|
|
# https://github.com/googleapis/python-ndb/issues/743#issuecomment-2067590945
|
|
|
|
#
|
|
|
|
# fixes "RuntimeError: Key has already been set in this batch" errors due to
|
|
|
|
# tasklets in pages.serve_feed
|
|
|
|
from logging import error as log_error
|
|
|
|
from sys import modules
|
|
|
|
|
|
|
|
from google.cloud.datastore_v1.types.entity import Key
|
|
|
|
from google.cloud.ndb._cache import (
|
|
|
|
_GlobalCacheSetBatch,
|
|
|
|
global_compare_and_swap,
|
|
|
|
global_set_if_not_exists,
|
|
|
|
global_watch,
|
|
|
|
)
|
|
|
|
from google.cloud.ndb.tasklets import Future, Return, tasklet
|
|
|
|
|
|
|
|
GLOBAL_CACHE_KEY_PREFIX: bytes = modules["google.cloud.ndb._cache"]._PREFIX
|
|
|
|
LOCKED_FOR_READ: bytes = modules["google.cloud.ndb._cache"]._LOCKED_FOR_READ
|
|
|
|
LOCK_TIME: bytes = modules["google.cloud.ndb._cache"]._LOCK_TIME
|
|
|
|
|
|
|
|
|
|
|
|
@tasklet
|
|
|
|
def custom_global_lock_for_read(key: str, value: str):
|
|
|
|
if value is not None:
|
|
|
|
yield global_watch(key, value)
|
|
|
|
lock_acquired = yield global_compare_and_swap(
|
|
|
|
key, LOCKED_FOR_READ, expires=LOCK_TIME
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
lock_acquired = yield global_set_if_not_exists(
|
|
|
|
key, LOCKED_FOR_READ, expires=LOCK_TIME
|
|
|
|
)
|
|
|
|
|
|
|
|
if lock_acquired:
|
|
|
|
raise Return(LOCKED_FOR_READ)
|
|
|
|
|
|
|
|
modules["google.cloud.ndb._cache"].global_lock_for_read = custom_global_lock_for_read
|