├── .gitignore ├── CHANGES ├── LICENSE ├── README.md ├── douban ├── __init__.py └── mc │ ├── __init__.py │ ├── debug.py │ ├── decorator.py │ ├── util.py │ └── wrapper.py ├── requirements.txt ├── setup.py └── tests └── test_mc.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/* 3 | *.py[cod] 4 | *~ 5 | *.swp 6 | venv 7 | .ropeproject 8 | *.egg 9 | *.egg-info 10 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Version 0.0.1 (2014-01-17) 2 | ------------------------- 3 | * Initial code from douban. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Douban Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of the Douban Inc. nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL DOUBAN INC. BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Douban MC 2 | 3 | #### Dependency 4 | 5 | [Douban Utils](https://github.com/douban/douban-utils) 6 | 7 | 8 | #### Quick Start 9 | 10 | ``` 11 | 1. virtualenv venv 12 | 2. . venv/bin/activate 13 | 3. memcached -p 11211 14 | 4. memcached -p 11212 15 | 5. memcached -p 11213 16 | 6. python setup.py install 17 | 7. python setup.py test 18 | ``` 19 | -------------------------------------------------------------------------------- /douban/__init__.py: -------------------------------------------------------------------------------- 1 | # See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages 2 | try: 3 | __import__('pkg_resources').declare_namespace(__name__) 4 | except ImportError: 5 | from pkgutil import extend_path 6 | __path__ = extend_path(__path__, __name__) 7 | -------------------------------------------------------------------------------- /douban/mc/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import random 5 | import socket 6 | import traceback 7 | from warnings import warn 8 | 9 | import cmemcached 10 | 11 | from douban.utils.config import read_config 12 | from douban.utils import hashdict 13 | 14 | from douban.utils.slog import log as slog 15 | from functools import wraps 16 | 17 | log = lambda message: slog('memcached', message) 18 | 19 | def create_mc(addr, **kwargs): 20 | client = cmemcached.Client(addr, comp_threshold=1024, logger = log, **kwargs) 21 | client.set_behavior(cmemcached.BEHAVIOR_CONNECT_TIMEOUT, 10) # 0.01s 22 | client.set_behavior(cmemcached.BEHAVIOR_POLL_TIMEOUT, 300) # 0.3s 23 | client.set_behavior(cmemcached.BEHAVIOR_RETRY_TIMEOUT, 5) # 5 sec 24 | client.set_behavior(cmemcached.BEHAVIOR_SERVER_FAILURE_LIMIT, 2) # not used in v1.0 25 | return client 26 | 27 | MUTABLE_ATTR = ('set', 'delete', 'set_multi', 'delete_multi') 28 | 29 | def async_clean(func, async): 30 | @wraps(func) 31 | def _(arg_with_sense, *a, **kw): 32 | r = func(arg_with_sense, *a, **kw) 33 | if not r: 34 | if isinstance(arg_with_sense, str): 35 | async(arg_with_sense) 36 | elif isinstance(arg_with_sense, list) or \ 37 | isinstance(arg_with_sense, dict): 38 | for k in arg_with_sense: 39 | async(k) 40 | else: 41 | raise Exception('calling MCManager with wrong type argument') 42 | return r 43 | return _ 44 | 45 | 46 | class MCManager(object): 47 | def __init__(self, config, async_cleaner=None, **kwargs): 48 | self.mc = None 49 | self.mc_config_path = None 50 | self.mc_config_version = None 51 | self.mc_config = None 52 | self.mc_config_change_history = [] 53 | self.cfgreloader = None 54 | self.kwargs = kwargs 55 | self.parse_config(config) 56 | if async_cleaner is not None: 57 | # replace set/set_multi/delete/delete_multi behaviour 58 | # with wrapped edtion 59 | for attr in MUTABLE_ATTR: 60 | method = getattr(self.mc, attr) 61 | new_method = async_clean(method, async_cleaner) 62 | setattr(self.mc, attr, new_method) 63 | 64 | def parse_config(self, config): 65 | cfgreloader_conf = config.get('cfgreloader', {}) 66 | self.mc_config_path = cfgreloader_conf.get('config_path', None) 67 | 68 | # don't explicitly close mc 69 | # http://code.dapps.douban.com/douban-corelib/commit/9a2884b35d0294169297b13023cf3d03300faa89#commit-linecomment-522 70 | #if self.mc: 71 | # try: 72 | # self.mc.close() 73 | # except Exception, exc: 74 | # print >> sys.stderr, 'Failed closing mc: ', exc 75 | 76 | # don't re-create mc if config does not change 77 | if self.mc_config == config and self.mc: 78 | return False 79 | 80 | hostname = socket.gethostname() 81 | disabled = config.get('disabled', False) 82 | in_disabled_list = hostname in config.get('disabled_client_hosts', []) 83 | disabled_via_env = os.environ.get('DOUBAN_CORELIB_DISABLE_MC', False) 84 | if disabled or in_disabled_list or disabled_via_env: 85 | from .debug import FakeMemcacheClient 86 | _mc = FakeMemcacheClient() 87 | else: 88 | _mc = create_mc(config.get('servers'), **self.kwargs) 89 | 90 | from .wrapper import AdjustMC, Replicated 91 | new_servers = config.get('new_servers',[]) 92 | if new_servers: 93 | _mc = AdjustMC(_mc, create_mc(new_servers, **self.kwargs)) 94 | 95 | backup_servers = config.get('backup_servers',[]) 96 | if backup_servers: 97 | _mc = Replicated(_mc, create_mc(backup_servers, **self.kwargs)) 98 | 99 | if config.get('log_every_actions', False) and os.getpid() % 25 == 0: 100 | from douban.mc.debug import LogMemcache 101 | _mc = LogMemcache(_mc) 102 | 103 | self.mc_config = config 104 | self.mc = _mc 105 | 106 | if self.mc_config_path: 107 | try: 108 | from douban.cfgreloader import cfgreloader 109 | self.cfgreloader = cfgreloader 110 | except Exception, exc: 111 | warn('Failed creating cfgreloader: %s' % exc) 112 | 113 | if self.cfgreloader: 114 | try: 115 | self.cfgreloader.register(self.mc_config_path, 116 | self.receive_conf, 117 | identity=self) 118 | except Exception, exc: 119 | print >> sys.stderr, \ 120 | 'Failed registering callback', self.receive_conf, \ 121 | 'for path', self.mc_config_path, ':', exc 122 | 123 | return True 124 | 125 | def receive_conf(self, data, version=None, mtime=None): 126 | ''' callback function for cfgreloader to reload lastest config 127 | ''' 128 | 129 | if self.mc_config_version == version: 130 | return (True, '') 131 | 132 | try: 133 | config = eval(data) 134 | time.sleep(random.random()*3) 135 | if self.parse_config(config): 136 | self.mc_config_version = version 137 | version_info = {'time': time.time(), 'version': version} 138 | self.mc_config_change_history.append(version_info) 139 | return (True, '') 140 | except Exception, exc: 141 | msg = 'in douban.mc.MCManager.receive_conf, ' 142 | msg += 'Failed parsing config received from cfgreloader: %s' 143 | msg = msg % exc 144 | msg += ''.join(traceback.format_stack()) 145 | return (False, msg) 146 | 147 | def __getattr__(self, name): 148 | if name == 'mc': 149 | raise AttributeError 150 | return getattr(self.mc, name) 151 | 152 | def __repr__(self): 153 | return 'MCManager (%r)' % self.mc 154 | 155 | _clients = {} 156 | def mc_from_config(config, use_cache = True, async_cleaner = None, **kwargs): 157 | if isinstance(config, basestring): 158 | config = read_config(config, 'mc') 159 | 160 | cache_key = '' 161 | if use_cache: 162 | cache_key = hashdict([config, kwargs]) 163 | mc = _clients.get(cache_key) 164 | if mc: 165 | return mc 166 | 167 | mc = MCManager(config, async_cleaner = async_cleaner, **kwargs) 168 | if use_cache and cache_key: 169 | _clients[cache_key] = mc 170 | 171 | return mc 172 | 173 | from .decorator import create_decorators 174 | -------------------------------------------------------------------------------- /douban/mc/debug.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | """ 4 | debug.py 5 | """ 6 | 7 | import time 8 | import sys 9 | from itertools import izip 10 | from cPickle import dumps 11 | 12 | class LocalMemcache(object): 13 | 14 | def __init__(self): 15 | self.dataset = {} 16 | 17 | def set(self, key, val, time=0, compress=True): 18 | _, version = self.dataset.get(key, (None, -1)) 19 | self.dataset[key] = (val, version + 1) 20 | return True 21 | 22 | def add(self, key, val): 23 | if not self.dataset.has_key(key): 24 | self.dataset[key] = (val, 1) 25 | return True 26 | else: 27 | return False 28 | 29 | 30 | def set_multi(self, values, time=0, compress = True, return_failure = False): 31 | for k, v in values.iteritems(): 32 | _, version = self.dataset.get(k, (None, -1)) 33 | self.dataset[k] = (v, version + 1) 34 | if return_failure: 35 | return True, [] 36 | else: return True 37 | 38 | def cas(self, key, val, time = 0, cas = 0): 39 | if key in self.dataset: 40 | _, version = self.dataset.get(key) 41 | if version == cas: 42 | self.dataset[key] = (val, version + 1) 43 | return True 44 | return False 45 | 46 | def delete(self, key, time=0): 47 | if key in self.dataset: 48 | del self.dataset[key] 49 | return 1 50 | 51 | def delete_multi(self, keys, time=0, return_failure = False): 52 | for k in keys: 53 | self.delete(k) 54 | if return_failure: 55 | return True, [] 56 | else: return True 57 | 58 | def get(self, key): 59 | return self.dataset.get(key, (None, 0))[0] 60 | 61 | def gets(self, key): 62 | return self.dataset.get(key, (None, 0)) 63 | 64 | def get_raw(self, key): 65 | raise NotImplementedError() 66 | 67 | def get_multi(self, keys): 68 | rets = {} 69 | for k in keys: 70 | r = self.dataset.get(k) 71 | if r is not None: 72 | rets[k] = r[0] 73 | return rets 74 | 75 | def get_list(self, keys): 76 | return [self.dataset.get(k)[0] for k in keys] 77 | 78 | def incr(self, key, val=1): 79 | raise NotImplementedError() 80 | 81 | def decr(self, key, val=1): 82 | raise NotImplementedError() 83 | 84 | def clear(self): 85 | self.dataset.clear() 86 | 87 | def get_last_error(self): 88 | return 0 89 | 90 | class FakeMemcacheClient(object): 91 | def set(self, key, val, expire_secs=0, compress=True): 92 | return 1 93 | 94 | def set_multi(self, values, expire_secs=0, compress=True): 95 | return 1 96 | 97 | def delete(self, key, timeout=0): 98 | return 1 99 | 100 | def delete_multi(self, keys): 101 | return 1 102 | 103 | def get(self, key): 104 | return None 105 | 106 | def get_raw(self, key): 107 | return None 108 | 109 | def get_multi(self, keys): 110 | return {} 111 | 112 | def get_list(self, keys): 113 | return [None] * len(keys) 114 | 115 | def incr(self, key, val=1): 116 | return 0 117 | 118 | def decr(self, key, val=1): 119 | return 0 120 | 121 | def clear(self): 122 | return 123 | 124 | def close(self): 125 | return 126 | 127 | def get_last_error(self): 128 | return 0 129 | 130 | def prepend_multi(self, *args, **kws): 131 | return 132 | 133 | def append(self, *args, **kws): 134 | return 135 | 136 | def add(self, *args, **kws): 137 | return 1 138 | 139 | class LogMemcache: 140 | def __init__(self, mc): 141 | self.mc = mc 142 | 143 | def dumps(self, val): 144 | if val is None: 145 | return '' 146 | if isinstance(val, basestring): 147 | pass 148 | elif isinstance(val, int) or isinstance(val, long): 149 | val = str(val) 150 | else: 151 | val = dumps(val, -1) 152 | return val 153 | 154 | def log(self, s): 155 | print >> sys.stderr, "[%s] memcache %s" % ( 156 | time.strftime("%Y-%m-%d %H:%M:%S"), s) 157 | 158 | def set(self, key, val, expire_secs=0): 159 | self.log("set %r:%d" % (key, len(self.dumps(val)))) 160 | return self.mc.set(key, val, expire_secs) 161 | 162 | def delete(self, key, timeout=0): 163 | self.log("delete %r" % key) 164 | return self.mc.delete(key, timeout) 165 | 166 | def get(self, key): 167 | val = self.mc.get(key) 168 | self.log("get %r:%d" % (key, len(self.dumps(val)))) 169 | return val 170 | 171 | def get_multi(self, keys): 172 | vals = self.mc.get_multi(keys) 173 | self.log("get_multi %s" % (", ".join( 174 | "%r:%d" % (k, len(self.dumps(v))) 175 | for k, v in vals.iteritems()))) 176 | return vals 177 | 178 | def get_list(self, keys): 179 | vals = self.mc.get_list(keys) 180 | self.log("get_list %s" % (", ".join( 181 | "%r:%d" % (k, len(self.dumps(v))) 182 | for k, v in izip(keys, vals)))) 183 | return vals 184 | 185 | def incr(self, key, val=1): 186 | self.log("incr %r" % key) 187 | return self.mc.incr(key, val) 188 | 189 | def decr(self, key, val=1): 190 | self.log("decr %r" % key) 191 | return self.mc.decr(key, val) 192 | 193 | def close(self): 194 | self.mc.close() 195 | -------------------------------------------------------------------------------- /douban/mc/decorator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | ''' memcache decorator ''' 5 | 6 | import inspect 7 | from functools import wraps 8 | import sys 9 | import time 10 | import struct 11 | from warnings import warn 12 | 13 | from douban.utils import format, Empty 14 | 15 | 16 | _MC_CHUNK_SIZE = 1000000 - 1000 # from python-libmemcached, split_mc.h 17 | 18 | 19 | def gen_key(key_pattern, arg_names, defaults, *a, **kw): 20 | return gen_key_factory(key_pattern, arg_names, defaults)(*a, **kw) 21 | 22 | def gen_key_factory(key_pattern, arg_names, defaults): 23 | args = dict(zip(arg_names[-len(defaults):], defaults)) if defaults else {} 24 | if callable(key_pattern): 25 | names = inspect.getargspec(key_pattern)[0] 26 | def gen_key(*a, **kw): 27 | aa = args.copy() 28 | aa.update(zip(arg_names, a)) 29 | aa.update(kw) 30 | if callable(key_pattern): 31 | key = key_pattern(*[aa[n] for n in names]) 32 | else: 33 | key = format(key_pattern, *[aa[n] for n in arg_names], **aa) 34 | return key and key.replace(' ','_'), aa 35 | return gen_key 36 | 37 | def cache(key_pattern, mc, expire=0, max_retry=0): 38 | def deco(f): 39 | arg_names, varargs, varkw, defaults = inspect.getargspec(f) 40 | if varargs or varkw: 41 | raise Exception("do not support varargs") 42 | gen_key = gen_key_factory(key_pattern, arg_names, defaults) 43 | @wraps(f) 44 | def _(*a, **kw): 45 | key, args = gen_key(*a, **kw) 46 | if not key: 47 | return f(*a, **kw) 48 | force = kw.pop('force', False) 49 | r = mc.get(key) if not force else None 50 | 51 | # anti miss-storm 52 | retry = max_retry 53 | while r is None and retry > 0: 54 | # when node is down, add() will failed 55 | if mc.add(key + '#mutex', 1, int(max_retry * 0.1)): 56 | break 57 | time.sleep(0.1) 58 | r = mc.get(key) 59 | retry -= 1 60 | 61 | if r is None: 62 | r = f(*a, **kw) 63 | if r is not None: 64 | mc.set(key, r, expire) 65 | if max_retry > 0: 66 | mc.delete(key + '#mutex') 67 | 68 | if isinstance(r, Empty): 69 | r = None 70 | return r 71 | _.original_function = f 72 | return _ 73 | return deco 74 | 75 | def pcache(key_pattern, mc, count=300, expire=0, max_retry=0): 76 | def deco(f): 77 | arg_names, varargs, varkw, defaults = inspect.getargspec(f) 78 | if varargs or varkw: 79 | raise Exception("do not support varargs") 80 | if not ('limit' in arg_names): 81 | raise Exception("function must has 'limit' in args") 82 | gen_key = gen_key_factory(key_pattern, arg_names, defaults) 83 | @wraps(f) 84 | def _(*a, **kw): 85 | key, args = gen_key(*a, **kw) 86 | start = args.pop('start', 0) 87 | limit = args.pop('limit') 88 | start = int(start) 89 | limit = int(limit) 90 | if not key or limit is None or start+limit > count: 91 | return f(*a, **kw) 92 | 93 | force = kw.pop('force', False) 94 | r = mc.get(key) if not force else None 95 | 96 | # anti miss-storm 97 | retry = max_retry 98 | while r is None and retry > 0: 99 | # when node is down, add() will failed 100 | if mc.add(key + '#mutex', 1, int(max_retry*0.1)): 101 | break 102 | print >>sys.stderr, "@cache(): wait for ", key, 'to return' 103 | time.sleep(0.1) 104 | r = mc.get(key) 105 | retry -= 1 106 | 107 | if r is None: 108 | r = f(limit=count, **args) 109 | mc.set(key, r, expire) 110 | mc.delete(key + '#mutex') 111 | return r[start:start+limit] 112 | _.original_function = f 113 | return _ 114 | return deco 115 | 116 | def pcache2(key_pattern, mc, count=300, expire=0): 117 | def deco(f): 118 | arg_names, varargs, varkw, defaults = inspect.getargspec(f) 119 | if varargs or varkw: 120 | raise Exception("do not support varargs") 121 | if not ('limit' in arg_names): 122 | raise Exception("function must has 'limit' in args") 123 | gen_key = gen_key_factory(key_pattern, arg_names, defaults) 124 | @wraps(f) 125 | def _(*a, **kw): 126 | key, args = gen_key(*a, **kw) 127 | start = args.pop('start', 0) 128 | limit = args.pop('limit') 129 | if not key or limit is None or start+limit > count: 130 | return f(*a, **kw) 131 | 132 | n = 0 133 | force = kw.pop('force', False) 134 | d = mc.get(key) if not force else None 135 | if d is None: 136 | n, r = f(limit=count, **args) 137 | mc.set(key, (n, r), expire) 138 | else: 139 | n, r = d 140 | return (n, r[start:start+limit]) 141 | _.original_function = f 142 | return _ 143 | return deco 144 | 145 | def listcache(key_pattern, mc, expire=0, fmt='I'): 146 | "cache list(int) using struct.pack, for append/prepend" 147 | def deco(f): 148 | arg_names, varargs, varkw, defaults = inspect.getargspec(f) 149 | if varargs or varkw: 150 | raise Exception("do not support varargs") 151 | gen_key = gen_key_factory(key_pattern, arg_names, defaults) 152 | size = struct.calcsize(fmt) 153 | @wraps(f) 154 | def _(*a, **kw): 155 | key, args = gen_key(*a, **kw) 156 | if not key: 157 | return f(*a, **kw) 158 | force = kw.pop('force', False) 159 | r = mc.get(key) if not force else None 160 | if r and len(r) > _MC_CHUNK_SIZE: 161 | # python-libmemcached会将大于`CHUNK_SIZE`的值split为多个再set 162 | # 会让`append/prepend`行为不符合预期 163 | # 这里认为接近`CHUNK_SIZE`的值都可能是有错的 164 | r = None 165 | if r is not None and len(r)%size == 0: 166 | r = struct.unpack(fmt*(len(r)/size), r) 167 | else: 168 | r = f(*a, **kw) 169 | if isinstance(r, (list, tuple)): 170 | mc.set(key, struct.pack(fmt*len(r), *r), expire, compress=False) 171 | else: 172 | warn("func %s (%s) should return list or tuple" % (f.__name__, key)) 173 | return r 174 | _.original_function = f 175 | return _ 176 | return deco 177 | 178 | def delete_cache(key_pattern,mc): 179 | def deco(f): 180 | arg_names, varargs, varkw, defaults = inspect.getargspec(f) 181 | if varargs or varkw: 182 | raise Exception("do not support varargs") 183 | gen_key = gen_key_factory(key_pattern, arg_names, defaults) 184 | @wraps(f) 185 | def _(*a, **kw): 186 | key, args = gen_key(*a, **kw) 187 | r = f(*a, **kw) 188 | mc.delete(key) 189 | return r 190 | return _ 191 | _.original_function = f 192 | return deco 193 | 194 | def cache_in_obj(key, mc, expire=0): 195 | def deco(f): 196 | @wraps(f) 197 | def _(obj, *a, **kw): 198 | name = '_cached_' + f.__name__ 199 | force = kw.pop('force', False) 200 | v = getattr(obj, name, None) if not force else None 201 | if v is None: 202 | v = f(obj, *a, **kw) 203 | if v is not None: 204 | setattr(obj, name, v) 205 | mc.set(key % obj.id, obj, expire) 206 | return v 207 | _.original_function = f 208 | return _ 209 | return deco 210 | 211 | def create_decorators(mc): 212 | # 因为cache的调用有太多对expire参数的非关键字调用,因此没法用partial方式生成函数 213 | 214 | def _cache(key_pattern, expire=0, mc=mc, max_retry=0): 215 | return cache(key_pattern, mc, expire=expire, max_retry=max_retry) 216 | 217 | def _pcache(key_pattern, count=300, expire=0, max_retry=0): 218 | return pcache(key_pattern, mc, count=count, expire=expire, max_retry=max_retry) 219 | 220 | def _pcache2(key_pattern, count=300, expire=0): 221 | return pcache2(key_pattern, count=count, expire=expire, mc=mc) 222 | 223 | def _listcache(key_pattern, expire=0): 224 | return listcache(key_pattern, expire=expire, mc=mc) 225 | 226 | def _cache_in_obj(key, expire=0): 227 | return cache_in_obj(key, expire=expire, mc=mc) 228 | 229 | def _delete_cache(key_pattern): 230 | return delete_cache(key_pattern, mc=mc) 231 | 232 | return dict(cache=_cache, pcache=_pcache, 233 | pcache2=_pcache2, listcache=_listcache, 234 | cache_in_obj=_cache_in_obj, 235 | delete_cache=_delete_cache) 236 | 237 | 238 | -------------------------------------------------------------------------------- /douban/mc/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from cStringIO import StringIO 4 | from operator import itemgetter 5 | 6 | from douban.utils.debug import ObjCallLogger 7 | 8 | class LogMixin(object): 9 | 10 | def start_log(self): 11 | self.mc = ObjCallLogger(self.mc) 12 | 13 | def stop_log(self): 14 | if isinstance(self.mc, ObjCallLogger): 15 | self.mc = self.mc.obj 16 | 17 | def get_log(self, detail=False): 18 | from collections import defaultdict 19 | d = defaultdict(int) 20 | nd = defaultdict(lambda: [0, 0]) 21 | for call, ncall, cost in self.mc.log: 22 | d[call] += 1 23 | x = nd[ncall] 24 | x[0] += cost 25 | x[1] += 1 26 | sio = StringIO() 27 | print >> sio, "Memcache access (%s/%s calls):" % (len(d), 28 | sum(d.itervalues())) 29 | print >> sio 30 | for ncall, (cost, times) in sorted(nd.iteritems(), key=itemgetter(1), 31 | reverse=True): 32 | print >> sio, "%s: %d times, %f seconds" % (ncall, times, cost) 33 | print >> sio 34 | if detail: 35 | print >> sio, "Detail:" 36 | print >> sio 37 | for key, n in sorted(d.iteritems()): 38 | print >> sio, "%s: %d times" % (key, n) 39 | print >> sio 40 | return sio.getvalue() 41 | 42 | -------------------------------------------------------------------------------- /douban/mc/wrapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from random import randint 4 | from hashlib import md5 5 | 6 | import cmemcached 7 | 8 | from .util import LogMixin 9 | 10 | class AdjustMC(object): 11 | def __init__(self, oldmc, newmc): 12 | self._oldmc = oldmc 13 | self._newmc = newmc 14 | 15 | def moved(self, key): 16 | return self._oldmc.get_host_by_key(key) != self._newmc.get_host_by_key(key) 17 | 18 | def get(self, key): 19 | v = self._newmc.get(key) 20 | if v is None and self.moved(key): 21 | v = self._oldmc.get(key) 22 | if v is not None: 23 | self._newmc.set(key, v, 3600 + randint(0, 3600)) 24 | self._oldmc.delete(key) 25 | return v 26 | 27 | def get_multi(self, keys): 28 | r = self._newmc.get_multi(keys) 29 | rs = self._oldmc.get_multi([k for k in keys if k not in r and self.moved(k)]) 30 | for k,v in rs.iteritems(): 31 | self._newmc.set(k, v, 3600 + randint(0, 3600)) 32 | self._oldmc.delete(k) 33 | r.update(rs) 34 | return r 35 | 36 | def get_list(self, keys): 37 | rs = self.get_multi(keys) 38 | return [rs.get(k) for k in keys] 39 | 40 | def clear(self): 41 | pass 42 | 43 | def close(self): 44 | self._oldmc.close() 45 | self._newmc.close() 46 | 47 | def reset(self): 48 | self._oldmc.reset() 49 | self._newmc.reset() 50 | 51 | def clear_thread_ident(self): 52 | self._oldmc.clear_thread_ident() 53 | self._newmc.clear_thread_ident() 54 | 55 | def __getattr__(self, name): 56 | if name in ('add','replace','set','cas','delete','incr','decr', 57 | 'prepend','append','touch','expire'): 58 | def func(key, *args, **kwargs): 59 | if self.moved(key): 60 | self._oldmc.delete(key) 61 | return getattr(self._newmc, name)(key, *args, **kwargs) 62 | return func 63 | elif name in ('append_multi', 'prepend_multi', 'delete_multi', 64 | 'set_multi'): 65 | def func(keys, *args, **kwargs): 66 | moved_keys = [k for k in keys if self.moved(k)] 67 | self._oldmc.delete_multi(moved_keys) 68 | return getattr(self._newmc, name)(keys, *args, **kwargs) 69 | return func 70 | elif not name.startswith('__'): 71 | def func(*args, **kwargs): 72 | return getattr(self.mc, name)(*args, **kwargs) 73 | return func 74 | raise AttributeError(name) 75 | 76 | class Replicated(LogMixin): 77 | "replacated memcached for fail-over" 78 | def __init__(self, master, rep): 79 | self.mc = master 80 | self.rep = rep 81 | 82 | def __repr__(self): 83 | return "replicated " + str(self.mc) 84 | 85 | def get(self, key): 86 | v = self.mc.get(key) 87 | if v is None: 88 | v = self.rep.get(key) 89 | if v is not None: 90 | self.mc.set(key, v, 60 * 10) 91 | return v 92 | 93 | def get_multi(self, keys): 94 | r = self.mc.get_multi(keys) 95 | rs = self.rep.get_multi([k for k in keys if k not in r]) 96 | for k,v in rs.items(): 97 | self.mc.set(k, v, 60 * 10) 98 | r.update(rs) 99 | return r 100 | 101 | def get_list(self, keys): 102 | rs = self.get_multi(keys) 103 | return [rs.get(k) for k in keys] 104 | 105 | def set(self, key, value, time=0, compress=True): 106 | if value is None: 107 | return 108 | # let key expire in rep first 109 | self.rep.set(key, value, time/2, compress) 110 | return self.mc.set(key, value, time, compress) 111 | 112 | def clear(self): 113 | pass 114 | 115 | def close(self): 116 | self.mc.close() 117 | self.rep.close() 118 | 119 | def reset(self): 120 | self.mc.reset() 121 | self.rep.reset() 122 | 123 | def clear_thread_ident(self): 124 | self.mc.clear_thread_ident() 125 | self.rep.clear_thread_ident() 126 | 127 | def __getattr__(self, name): 128 | if name in ('add','replace','delete','incr','decr', 129 | 'prepend','append','touch','expire'): 130 | def func(key, *args, **kwargs): 131 | self.rep.delete(key) 132 | return getattr(self.mc, name)(key, *args, **kwargs) 133 | return func 134 | elif name in ('append_multi', 'prepend_multi', 'delete_multi', 135 | 'set_multi'): 136 | def func(keys, *args, **kwargs): 137 | self.rep.delete_multi(keys) 138 | return getattr(self.mc, name)(keys, *args, **kwargs) 139 | return func 140 | elif not name.startswith('__'): 141 | def func(*args, **kwargs): 142 | return getattr(self.mc, name)(*args, **kwargs) 143 | return func 144 | raise AttributeError(name) 145 | 146 | 147 | class LocalCached(LogMixin): 148 | " cache obj in local process, wrapper for memcache " 149 | def __init__(self, mc_client, size=10000): 150 | self.dataset = {} 151 | self.mc = mc_client 152 | self.size = size 153 | 154 | def clear(self): 155 | self.dataset.clear() 156 | if hasattr(self.mc, 'clear'): 157 | self.mc.clear() 158 | 159 | def _cache(self, key, value): 160 | if len(self.dataset) >= self.size: 161 | self.dataset.clear() 162 | self.dataset[key] = value 163 | 164 | def __repr__(self): 165 | return "Locally Cached " + str(self.mc) 166 | 167 | def get(self, key): 168 | if key in self.dataset: 169 | return self.dataset[key] 170 | r = self.mc.get(key) 171 | if r is not None: 172 | self._cache(key, r) 173 | return r 174 | 175 | def gets(self, key): 176 | return self.mc.gets(key) 177 | 178 | def get_multi(self, keys): 179 | ds = self.dataset 180 | ds_get = ds.get 181 | r = dict((k, ds[k]) for k in keys if ds_get(k) is not None) 182 | missed = [k for k in keys if k not in ds] 183 | if missed: 184 | rs = self.mc.get_multi(missed) 185 | r.update(rs) 186 | ds.update(dict((k, rs.get(k)) for k in missed)) 187 | return r 188 | 189 | def get_list(self, keys): 190 | rs = self.get_multi(keys) 191 | return [rs.get(k) for k in keys] 192 | 193 | def set(self, key, value, time=0, compress=True): 194 | self._cache(key, value) 195 | return self.mc.set(key, value, time, compress) 196 | 197 | def cas(self, key, value, time=0, cas=0): 198 | if self.mc.cas(key, value, time, cas): 199 | self._cache(key, value) 200 | return True 201 | else: 202 | self.dataset.pop(key, None) # FIXME 203 | return False 204 | 205 | def __getattr__(self, name): 206 | if name in ('add','replace','delete','incr','decr', 207 | 'prepend','append','touch','expire'): 208 | def func(key, *args, **kwargs): 209 | self.dataset.pop(key, None) 210 | return getattr(self.mc, name)(key, *args, **kwargs) 211 | return func 212 | elif name in ('append_multi', 'prepend_multi', 'delete_multi', 'set_multi'): 213 | def func(keys, *args, **kwargs): 214 | for k in keys: 215 | self.dataset.pop(k, None) 216 | return getattr(self.mc, name)(keys, *args, **kwargs) 217 | return func 218 | elif not name.startswith('__'): 219 | def func(*args, **kwargs): 220 | return getattr(self.mc, name)(*args, **kwargs) 221 | return func 222 | raise AttributeError(name) 223 | 224 | def reset(self): 225 | self.mc.reset() 226 | self.clear() 227 | 228 | class VersionedLocalCached(object): 229 | def __init__(self, _mc): 230 | self.mc = _mc 231 | self.dataset = {} 232 | 233 | def get(self, key): 234 | ver = self.mc.get(key+':VER2') 235 | if ver is None: 236 | return None 237 | val, cached_ver = self.dataset.get(key, (None, None)) 238 | if cached_ver != ver: 239 | val = self.mc.get(key+':V_'+ver) 240 | if val is None: 241 | return None 242 | self.dataset[key] = (val, ver) 243 | return val 244 | 245 | def add(self, key, value, time=0): 246 | self.dataset.pop(key, None) 247 | ver = self._get_version(value) 248 | if self.mc.add(key+':VER2', ver, time): 249 | self.mc.set(key+':V_'+ver, value, time) 250 | return 1 251 | return 0 252 | 253 | def set(self, key, value, time=0): 254 | if value is None: 255 | return 256 | ver = self._get_version(value) 257 | r = self.mc.set(key+':V_'+ver, value, time) 258 | self.mc.set(key+':VER2', ver, time) 259 | self.dataset[key] = (value, ver) 260 | return r 261 | 262 | def get_multi(self, keys): 263 | #TODO to optimize 264 | d = {} 265 | for key in keys: 266 | val = self.get(key) 267 | if val is not None: 268 | d[key] = val 269 | return d 270 | 271 | def get_list(self, keys): 272 | #TODO to optimize 273 | return [self.get(k) for k in keys] 274 | 275 | def delete(self, key): 276 | self.dataset.pop(key, None) 277 | return self.mc.delete(key+':VER2') 278 | 279 | def touch(self, key, exptime): 280 | return self.mc.touch(key+':VER2', exptime) 281 | 282 | def expire(self, key): 283 | self.dataset.pop(key, None) 284 | return self.mc.expire(key+':VER2') 285 | 286 | def _get_version(self, value): 287 | serialed, flag = cmemcached.prepare(value, 0) 288 | return md5(serialed).hexdigest() 289 | 290 | def _get_version(self, value): 291 | serialized, flag = cmemcached.prepare(value, 0) 292 | return md5(serialized).hexdigest() 293 | 294 | class SyncMC(object): 295 | def __init__(self, main_mc, sync_mc): 296 | self.mc = main_mc 297 | self.sync_mc = sync_mc 298 | 299 | def clear_thread_ident(self): 300 | self.mc.clear_thread_ident() 301 | self.sync_mc.clear_thread_ident() 302 | 303 | def reset(self): 304 | self.mc.reset() 305 | self.sync_mc.reset() 306 | 307 | def __getattr__(self, name): 308 | if name in ('add', 'replace', 'set', 'delete','incr','decr', 309 | 'append','prepend','expire','touch'): 310 | def func(key, *args, **kwargs): 311 | self.sync_mc.delete(key) 312 | return getattr(self.mc, name)(key, *args, **kwargs) 313 | return func 314 | else: 315 | return getattr(self.mc, name) 316 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Cython 2 | -e git+http://code.dapps.douban.com/python-libmemcached.git#egg=cmemcached 3 | -e git+http://code.dapps.douban.com/xutao/douban-utils.git#egg=douban-utils 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | # package meta info 5 | NAME = "DoubanMC" 6 | VERSION = "0.0.1" 7 | DESCRIPTION = "" 8 | AUTHOR = "Qiangning Hong" 9 | AUTHOR_EMAIL = "hongqn@gmail.com" 10 | LICENSE = "revised BSD" 11 | URL = "https://github.com/douban/douban-mc" 12 | KEYWORDS = "" 13 | CLASSIFIERS = [] 14 | 15 | # package contents 16 | MODULES = [] 17 | PACKAGES = find_packages(exclude=['tests.*', 18 | 'tests', 19 | 'examples.*', 20 | 'examples']) 21 | ENTRY_POINTS = """ 22 | """ 23 | 24 | # dependencies 25 | INSTALL_REQUIRES = [] 26 | TESTS_REQUIRE = ['mock', 'nose'] 27 | TEST_SUITE = 'nose.collector' 28 | 29 | here = os.path.abspath(os.path.dirname(__file__)) 30 | 31 | 32 | def read_long_description(filename): 33 | path = os.path.join(here, filename) 34 | if os.path.exists(path): 35 | return open(path).read() 36 | return "" 37 | 38 | setup( 39 | name=NAME, 40 | version=VERSION, 41 | description=DESCRIPTION, 42 | long_description=read_long_description('README.md'), 43 | author=AUTHOR, 44 | author_email=AUTHOR_EMAIL, 45 | license=LICENSE, 46 | url=URL, 47 | keywords=KEYWORDS, 48 | classifiers=CLASSIFIERS, 49 | py_modules=MODULES, 50 | packages=PACKAGES, 51 | install_package_data=True, 52 | zip_safe=False, 53 | entry_points=ENTRY_POINTS, 54 | install_requires=INSTALL_REQUIRES, 55 | tests_require=TESTS_REQUIRE, 56 | test_suite=TEST_SUITE, 57 | ) 58 | -------------------------------------------------------------------------------- /tests/test_mc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | """ test_mc.py 5 | """ 6 | 7 | import unittest 8 | import random 9 | import time 10 | 11 | import cmemcached 12 | from douban.mc import mc_from_config 13 | from douban.mc.wrapper import AdjustMC, Replicated, LocalCached, \ 14 | VersionedLocalCached 15 | from mock import patch, Mock, call 16 | 17 | class PureMCTest(unittest.TestCase): 18 | config = { 19 | 'servers': ['127.0.0.1:11211'], 20 | } 21 | mc = mc_from_config(config) 22 | 23 | def get_random_key(self): 24 | return 'MCTEST:%s:%s' % (time.time(), random.random()) 25 | 26 | def test_wrapper_is_right(self): 27 | self.assertTrue(isinstance(self.mc.mc, cmemcached.Client)) 28 | 29 | def test_incr_decr(self): 30 | key = self.get_random_key() 31 | 32 | self.assertTrue(self.mc.set(key, 10)) 33 | self.mc.incr(key) 34 | self.assertEqual(11, self.mc.get(key)) 35 | self.mc.decr(key, 10) 36 | self.assertEqual(1, self.mc.get(key)) 37 | self.mc.delete(key) 38 | 39 | def test_add_replace(self): 40 | key = self.get_random_key() 41 | val1 = str(random.random()) 42 | val2 = str(random.random()) 43 | 44 | self.assertTrue(self.mc.get(key) is None) 45 | self.assertTrue(self.mc.add(key, val1)) 46 | self.assertEquals(val1, self.mc.get(key)) 47 | 48 | self.assertFalse(self.mc.add(key, val2)) 49 | self.assertEqual(val1, self.mc.get(key)) 50 | 51 | self.mc.delete(key) 52 | self.assertTrue(self.mc.get(key) is None) 53 | self.assertFalse(self.mc.replace(key, val1)) 54 | self.assertTrue(self.mc.get(key) is None) 55 | self.mc.set(key, val1) 56 | self.assertEqual(val1, self.mc.get(key)) 57 | self.assertTrue(self.mc.replace(key, val2)) 58 | self.assertEqual(val2, self.mc.get(key)) 59 | 60 | self.mc.delete(key) 61 | 62 | def test_set_delete_append_prepend(self): 63 | key = self.get_random_key() 64 | val = str(random.random()) 65 | 66 | self.mc.delete(key) 67 | self.assertTrue(self.mc.get(key) is None) 68 | self.mc.set(key, val) 69 | self.assertEqual(val, self.mc.get(key)) 70 | 71 | self.mc.append(key, 'a') 72 | self.assertEqual(val+'a', self.mc.get(key)) 73 | 74 | self.mc.prepend(key, 'a') 75 | self.assertEqual('a'+val+'a', self.mc.get(key)) 76 | 77 | self.mc.delete(key) 78 | self.assertTrue(self.mc.get(key) is None) 79 | 80 | def test_multi_set_delete_append_prepend(self): 81 | key1, key2 = self.get_random_key(), self.get_random_key() 82 | val1, val2 = str(random.random()), str(random.random()) 83 | 84 | self.mc.delete_multi([key1, key2]) 85 | self.assertEqual({}, self.mc.get_multi([key1, key2])) 86 | 87 | data = {key1: val1, key2: val2} 88 | self.mc.set_multi(data) 89 | self.assertEqual(data, self.mc.get_multi([key1, key2])) 90 | 91 | self.mc.append_multi([key1, key2], 'a') 92 | self.assertEqual({key1: val1+'a', key2: val2+'a'}, self.mc.get_multi([key1, key2])) 93 | 94 | self.mc.prepend_multi([key1, key2], 'a') 95 | self.assertEqual({key1: 'a'+val1+'a', key2: 'a'+val2+'a'}, 96 | self.mc.get_multi([key1, key2])) 97 | 98 | self.mc.delete_multi([key1, key2]) 99 | self.assertEqual({}, self.mc.get_multi([key1, key2])) 100 | 101 | class AdjustMCTest(PureMCTest): 102 | config = { 103 | 'servers': ['127.0.0.1:11211'], 104 | 'new_servers': ['127.0.0.1:11212'], 105 | } 106 | mc = mc_from_config(config) 107 | 108 | def test_wrapper_is_right(self): 109 | self.assertTrue(isinstance(self.mc.mc, AdjustMC)) 110 | 111 | class ReplicatedTest(PureMCTest): 112 | config = { 113 | 'servers': ['127.0.0.1:11211'], 114 | 'new_servers': [], 115 | 'backup_servers': ['127.0.0.1:11213'], 116 | } 117 | mc = mc_from_config(config) 118 | 119 | def test_wrapper_is_right(self): 120 | self.assertTrue(isinstance(self.mc.mc, Replicated)) 121 | 122 | class LocalCachedTest(PureMCTest): 123 | config = { 124 | 'servers': ['127.0.0.1:11211'], 125 | } 126 | mc = LocalCached(mc_from_config(config)) 127 | 128 | def test_wrapper_is_right(self): 129 | self.assertTrue(isinstance(self.mc, LocalCached)) 130 | 131 | class VersionedLocalCachedTestCase(unittest.TestCase): 132 | def setUp(self): 133 | unittest.TestCase.setUp(self) 134 | 135 | config = { 136 | 'servers': ['127.0.0.1:11211'], 137 | } 138 | self.mc = mc_from_config(config) 139 | self.cache = VersionedLocalCached(self.mc) 140 | 141 | def test_get_should_return_None_when_not_exists_in_mc(self): 142 | r = self.cache.get('test') 143 | self.assertEqual(r, None) 144 | 145 | def test_get_should_return_None_when_only_version_exists(self): 146 | self.mc.set('key:VER2', '1234567890') 147 | r = self.cache.get('key') 148 | self.assertEqual(r, None) 149 | 150 | def test_get_should_return_None_when_no_version_exists(self): 151 | self.mc.delete('key:VER2') 152 | self.mc.set('key:V:1234567890', 1) 153 | r = self.cache.get('key') 154 | self.assertEqual(r, None) 155 | 156 | def test_get_should_return_value_after_set(self): 157 | self.cache.set('key', 1) 158 | r = self.cache.get('key') 159 | self.assertEqual(r, 1) 160 | 161 | def test_get_should_return_value_when_another_process_set_it(self): 162 | c2 = VersionedLocalCached(self.mc) 163 | c2.set('key', 1) 164 | r = self.cache.get('key') 165 | self.assertEqual(r, 1) 166 | 167 | def test_set_should_set_local_cache(self): 168 | self.cache.set('key', 1) 169 | self.assertTrue('key' in self.cache.dataset) 170 | 171 | def test_get_should_return_from_local_cache_when_version_matches(self): 172 | self.cache.set('key', 1) 173 | # change local cache for comparation 174 | value, version = self.cache.dataset['key'] 175 | self.cache.dataset['key'] = (2, version) 176 | r = self.cache.get('key') 177 | self.assertEqual(r, 2) 178 | 179 | def test_get_should_return_from_mc_when_cached_version_mismatches(self): 180 | self.cache.set('key', 1) 181 | c2 = VersionedLocalCached(self.mc) 182 | c2.set('key', 2) 183 | self.assertEqual(self.cache.dataset['key'][0], 1) 184 | r = self.cache.get('key') 185 | self.assertEqual(r, 2) 186 | self.assertEqual(self.cache.dataset['key'][0], 2) 187 | self.assertEqual(self.cache.dataset['key'][1], self.mc.get('key:VER2')) 188 | 189 | def test_get_multi_should_work(self): 190 | self.cache.set('key1', 1) 191 | self.cache.set('key2', 2) 192 | r = self.cache.get_multi(['key1', 'key2']) 193 | self.assertEqual(r, {'key1': 1, 'key2': 2}) 194 | 195 | def test_get_list_should_work(self): 196 | self.cache.set('key1', 1) 197 | self.cache.set('key2', 2) 198 | r = self.cache.get_list(['key1', 'key2']) 199 | self.assertEqual(r, [1, 2]) 200 | 201 | def test_delete_should_work(self): 202 | self.cache.set('key1', 1) 203 | self.cache.delete('key1') 204 | r = self.cache.get('key1') 205 | self.assertEqual(r, None) 206 | 207 | def test_version_should_be_consistent_for_same_value(self): 208 | self.cache.set('key1', 1) 209 | ver = self.mc.get('key1:VER2') 210 | self.cache.delete('key1') 211 | self.cache.set('key1', 1) 212 | ver2 = self.mc.get('key1:VER2') 213 | self.assertEqual(ver, ver2) 214 | 215 | class AsyncSendTest(unittest.TestCase): 216 | config = { 217 | 'servers' : ['127.0.0.1:11299'], 218 | } 219 | 220 | mq = Mock() 221 | def test_delete_fail_will_cause_async(self): 222 | self.mq.reset_mock() 223 | def async_log(key): 224 | self.mq.send(key) 225 | mc = mc_from_config(self.config, async_cleaner = async_log) 226 | mc.delete('test_key') 227 | self.mq.send.assert_called_with('test_key') 228 | 229 | def test_set_fail_will_cause_async(self): 230 | self.mq.reset_mock() 231 | def async_log(key): 232 | self.mq.send(key) 233 | mc = mc_from_config(self.config, async_cleaner = async_log) 234 | mc.set('test_set_key', 'test_value') 235 | self.mq.send.assert_called_with('test_set_key') 236 | #assert mq.send.called 237 | 238 | def test_set_multi_fail_will_cause_async(self): 239 | self.mq.reset_mock() 240 | async_log = lambda key: self.mq.send(key) 241 | mc = mc_from_config(self.config, async_cleaner = async_log) 242 | mc.set_multi({'key1':'value1', 'key2':'value2'}) 243 | assert self.mq.send.call_count == 2 244 | calls = self.mq.send.call_args_list 245 | for call in calls: 246 | assert call == call('key1') or call == call('key2') 247 | 248 | def test_delete_multi_fail_will_cause_async(self): 249 | self.mq.reset_mock() 250 | async_log = lambda key: self.mq.send(key) 251 | mc = mc_from_config(self.config, async_cleaner = async_log) 252 | mc.delete_multi(['key1', 'key2', 'key3']) 253 | assert self.mq.send.call_count == 3 254 | calls = self.mq.send.call_args_list 255 | for call in calls: 256 | assert call == call('key1') or call == call('key2') or \ 257 | call == call('key3') 258 | 259 | 260 | def test_get_fail_will_never_use_async(self): 261 | self.mq.reset_mock() 262 | async_log = lambda key: self.mq.send(key) 263 | mc = mc_from_config(self.config, async_cleaner = async_log) 264 | mc.get('test_key') 265 | assert not self.mq.send.called 266 | 267 | if __name__ == '__main__': 268 | unittest.main() 269 | --------------------------------------------------------------------------------