Skip to content
This repository was archived by the owner on May 6, 2026. It is now read-only.

Commit 2bd43da

Browse files
author
Chris Rossi
authored
feat: memcached integration (#536)
Adds a new `GlobalCache` implementation, `MemcacheCache`, which allows memcached to be used as a global cache. May be used with a Google Memorystore, or any configured memcached instance.
1 parent 3aa480a commit 2bd43da

9 files changed

Lines changed: 411 additions & 1 deletion

File tree

.kokoro/build.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@ export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/service-account.json
3434
# Setup project id.
3535
export PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json")
3636

37-
# Configure Local Redis to be used
37+
# Configure local Redis to be used
3838
export REDIS_CACHE_URL=redis://localhost
3939
redis-server &
4040

41+
# Configure local memcached to be used
42+
export MEMCACHED_HOSTS=localhost
43+
service memcached start
44+
4145
# Some system tests require indexes. Use gcloud to create them.
4246
gcloud auth activate-service-account --key-file=$GOOGLE_APPLICATION_CREDENTIALS --project=$PROJECT_ID
4347
gcloud --quiet --verbosity=debug datastore indexes create tests/system/index.yaml

.kokoro/docker/docs/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ RUN apt-get update \
3939
libsnappy-dev \
4040
libssl-dev \
4141
libsqlite3-dev \
42+
memcached \
4243
portaudio19-dev \
4344
redis-server \
4445
software-properties-common \

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
("py:class", "Tuple"),
5454
("py:class", "Union"),
5555
("py:class", "redis.Redis"),
56+
("py:class", "pymemcache.Client"),
5657
]
5758

5859
# Add any Sphinx extension module names here, as strings. They can be

google/cloud/ndb/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from google.cloud.ndb._datastore_query import Cursor
3939
from google.cloud.ndb._datastore_query import QueryIterator
4040
from google.cloud.ndb.global_cache import GlobalCache
41+
from google.cloud.ndb.global_cache import MemcacheCache
4142
from google.cloud.ndb.global_cache import RedisCache
4243
from google.cloud.ndb.key import Key
4344
from google.cloud.ndb.model import BlobKey
@@ -171,6 +172,7 @@
171172
"KindError",
172173
"LocalStructuredProperty",
173174
"make_connection",
175+
"MemcacheCache",
174176
"MetaModel",
175177
"Model",
176178
"ModelAdapter",

google/cloud/ndb/global_cache.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
"""GlobalCache interface and its implementations."""
1616

1717
import abc
18+
import base64
1819
import collections
1920
import os
2021
import threading
2122
import time
2223
import uuid
2324

25+
import pymemcache
2426
import redis as redis_module
2527

2628

@@ -282,3 +284,121 @@ def compare_and_swap(self, items, expires=None):
282284
self.pipes.pop(key, None)
283285

284286
return results
287+
288+
289+
class MemcacheCache(GlobalCache):
290+
"""Memcache implementation of the :class:`GlobalCache`.
291+
292+
This is a synchronous implementation. The idea is that calls to Memcache
293+
should be fast enough not to warrant the added complexity of an
294+
asynchronous implementation.
295+
296+
Args:
297+
client (pymemcache.Client): Instance of Memcache client to use.
298+
"""
299+
300+
@staticmethod
301+
def _parse_host_string(host_string):
302+
split = host_string.split(":")
303+
if len(split) == 1:
304+
return split[0], 11211
305+
306+
elif len(split) == 2:
307+
host, port = split
308+
try:
309+
port = int(port)
310+
return host, port
311+
except ValueError:
312+
pass
313+
314+
raise ValueError("Invalid memcached host_string: {}".format(host_string))
315+
316+
@staticmethod
317+
def _key(key):
318+
return base64.b64encode(key)
319+
320+
@classmethod
321+
def from_environment(cls, max_pool_size=4):
322+
"""Generate a ``pymemcache.Client`` from an environment variable.
323+
324+
This class method looks for the ``MEMCACHED_HOSTS`` environment
325+
variable and, if it is set, parses the value as a space delimited list of
326+
hostnames, optionally with ports. For example:
327+
328+
"localhost"
329+
"localhost:11211"
330+
"1.1.1.1:11211 2.2.2.2:11211 3.3.3.3:11211"
331+
332+
Returns:
333+
Optional[MemcacheCache]: A :class:`MemcacheCache` instance or
334+
:data:`None`, if ``MEMCACHED_HOSTS`` is not set in the
335+
environment.
336+
"""
337+
hosts_string = os.environ.get("MEMCACHED_HOSTS")
338+
if not hosts_string:
339+
return None
340+
341+
hosts = [
342+
cls._parse_host_string(host_string.strip())
343+
for host_string in hosts_string.split()
344+
]
345+
346+
if not max_pool_size:
347+
max_pool_size = 1
348+
349+
if len(hosts) == 1:
350+
client = pymemcache.PooledClient(hosts[0], max_pool_size=max_pool_size)
351+
352+
else:
353+
client = pymemcache.HashClient(
354+
hosts, use_pooling=True, max_pool_size=max_pool_size
355+
)
356+
357+
return cls(client)
358+
359+
def __init__(self, client):
360+
self.client = client
361+
self._cas = threading.local()
362+
363+
@property
364+
def caskeys(self):
365+
local = self._cas
366+
if not hasattr(local, "caskeys"):
367+
local.caskeys = {}
368+
return local.caskeys
369+
370+
def get(self, keys):
371+
"""Implements :meth:`GlobalCache.get`."""
372+
keys = [self._key(key) for key in keys]
373+
result = self.client.get_many(keys)
374+
return [result.get(key) for key in keys]
375+
376+
def set(self, items, expires=None):
377+
"""Implements :meth:`GlobalCache.set`."""
378+
items = {self._key(key): value for key, value in items.items()}
379+
expires = expires if expires else 0
380+
self.client.set_many(items, expire=expires)
381+
382+
def delete(self, keys):
383+
"""Implements :meth:`GlobalCache.delete`."""
384+
keys = [self._key(key) for key in keys]
385+
self.client.delete_many(keys)
386+
387+
def watch(self, keys):
388+
"""Implements :meth:`GlobalCache.watch`."""
389+
keys = [self._key(key) for key in keys]
390+
caskeys = self.caskeys
391+
for key, (value, caskey) in self.client.gets_many(keys).items():
392+
caskeys[key] = caskey
393+
394+
def compare_and_swap(self, items, expires=None):
395+
"""Implements :meth:`GlobalCache.compare_and_swap`."""
396+
caskeys = self.caskeys
397+
for key, value in items.items():
398+
key = self._key(key)
399+
caskey = caskeys.pop(key, None)
400+
if caskey is None:
401+
continue
402+
403+
expires = expires if expires else 0
404+
self.client.cas(key, value, caskey, expire=expires)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def main():
2626
readme = readme_file.read()
2727
dependencies = [
2828
"google-cloud-datastore >= 1.7.0",
29+
"pymemcache",
2930
"redis",
3031
]
3132

tests/system/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,11 @@ def redis_context(client_context):
149149
with client_context.new(global_cache=global_cache).use() as context:
150150
context.set_global_cache_policy(None) # Use default
151151
yield context
152+
153+
154+
@pytest.fixture
155+
def memcache_context(client_context):
156+
global_cache = global_cache_module.MemcacheCache.from_environment()
157+
with client_context.new(global_cache=global_cache).use() as context:
158+
context.set_global_cache_policy(None) # Use default
159+
yield context

tests/system/test_crud.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from . import KIND, eventually, equals
4040

4141
USE_REDIS_CACHE = bool(os.environ.get("REDIS_CACHE_URL"))
42+
USE_MEMCACHE = bool(os.environ.get("MEMCACHED_HOSTS"))
4243

4344

4445
def _assert_contemporaneous(timestamp1, timestamp2, delta_margin=2):
@@ -149,6 +150,37 @@ class SomeKind(ndb.Model):
149150
assert entity.baz == "night"
150151

151152

153+
@pytest.mark.skipif(not USE_MEMCACHE, reason="Memcache is not configured")
154+
def test_retrieve_entity_with_memcache(ds_entity, memcache_context):
155+
entity_id = test_utils.system.unique_resource_id()
156+
ds_entity(KIND, entity_id, foo=42, bar="none", baz=b"night")
157+
158+
class SomeKind(ndb.Model):
159+
foo = ndb.IntegerProperty()
160+
bar = ndb.StringProperty()
161+
baz = ndb.StringProperty()
162+
163+
key = ndb.Key(KIND, entity_id)
164+
entity = key.get()
165+
assert isinstance(entity, SomeKind)
166+
assert entity.foo == 42
167+
assert entity.bar == "none"
168+
assert entity.baz == "night"
169+
170+
cache_key = _cache.global_cache_key(key._key)
171+
cache_key = global_cache_module.MemcacheCache._key(cache_key)
172+
assert memcache_context.global_cache.client.get(cache_key) is not None
173+
174+
patch = mock.patch("google.cloud.ndb._datastore_api._LookupBatch.add")
175+
patch.side_effect = Exception("Shouldn't call this")
176+
with patch:
177+
entity = key.get()
178+
assert isinstance(entity, SomeKind)
179+
assert entity.foo == 42
180+
assert entity.bar == "none"
181+
assert entity.baz == "night"
182+
183+
152184
@pytest.mark.usefixtures("client_context")
153185
def test_retrieve_entity_not_found(ds_entity):
154186
entity_id = test_utils.system.unique_resource_id()
@@ -586,6 +618,33 @@ class SomeKind(ndb.Model):
586618
assert redis_context.global_cache.redis.get(cache_key) is None
587619

588620

621+
@pytest.mark.skipif(not USE_MEMCACHE, reason="Memcache is not configured")
622+
def test_insert_entity_with_memcache(dispose_of, memcache_context):
623+
class SomeKind(ndb.Model):
624+
foo = ndb.IntegerProperty()
625+
bar = ndb.StringProperty()
626+
627+
entity = SomeKind(foo=42, bar="none")
628+
key = entity.put()
629+
dispose_of(key._key)
630+
cache_key = _cache.global_cache_key(key._key)
631+
cache_key = global_cache_module.MemcacheCache._key(cache_key)
632+
assert memcache_context.global_cache.client.get(cache_key) is None
633+
634+
retrieved = key.get()
635+
assert retrieved.foo == 42
636+
assert retrieved.bar == "none"
637+
638+
assert memcache_context.global_cache.client.get(cache_key) is not None
639+
640+
entity.foo = 43
641+
entity.put()
642+
643+
# This is py27 behavior. I can see a case being made for caching the
644+
# entity on write rather than waiting for a subsequent lookup.
645+
assert memcache_context.global_cache.client.get(cache_key) is None
646+
647+
589648
@pytest.mark.usefixtures("client_context")
590649
def test_update_entity(ds_entity):
591650
entity_id = test_utils.system.unique_resource_id()
@@ -750,6 +809,30 @@ class SomeKind(ndb.Model):
750809
assert redis_context.global_cache.redis.get(cache_key) == b"0"
751810

752811

812+
@pytest.mark.skipif(not USE_MEMCACHE, reason="Memcache is not configured")
813+
def test_delete_entity_with_memcache(ds_entity, memcache_context):
814+
entity_id = test_utils.system.unique_resource_id()
815+
ds_entity(KIND, entity_id, foo=42)
816+
817+
class SomeKind(ndb.Model):
818+
foo = ndb.IntegerProperty()
819+
820+
key = ndb.Key(KIND, entity_id)
821+
cache_key = _cache.global_cache_key(key._key)
822+
cache_key = global_cache_module.MemcacheCache._key(cache_key)
823+
824+
assert key.get().foo == 42
825+
assert memcache_context.global_cache.client.get(cache_key) is not None
826+
827+
assert key.delete() is None
828+
assert memcache_context.global_cache.client.get(cache_key) is None
829+
830+
# This is py27 behavior. Not entirely sold on leaving _LOCKED value for
831+
# Datastore misses.
832+
assert key.get() is None
833+
assert memcache_context.global_cache.client.get(cache_key) == b"0"
834+
835+
753836
@pytest.mark.usefixtures("client_context")
754837
def test_delete_entity_in_transaction(ds_entity):
755838
entity_id = test_utils.system.unique_resource_id()

0 commit comments

Comments
 (0)