forked from snarfed/bridgy-fed
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemcache.py
More file actions
340 lines (257 loc) Β· 10.8 KB
/
Copy pathmemcache.py
File metadata and controls
340 lines (257 loc) Β· 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""Utilities for caching data in memcache.
TODO: move most or all of this to webutil?
"""
from datetime import datetime, timedelta, timezone
from enum import auto, Enum
import functools
import logging
import os
import re
import string
import config
from google.cloud.ndb._cache import global_cache_key
from google.cloud.ndb.global_cache import _InProcessGlobalCache, MemcacheCache
from google.cloud.ndb.key import Key
from oauth_dropins.webutil import appengine_info, util
from pymemcache.client.base import PooledClient
from pymemcache.serde import PickleSerde
from pymemcache.test.utils import MockMemcacheClient
from domains import PRIMARY_DOMAIN
logger = logging.getLogger(__name__)
# https://github.com/memcached/memcached/wiki/Commands#standard-protocol
KEY_MAX_LEN = 250
MEMOIZE_VERSION = 2
# per-user rates for running tasks. rate limits and spreads out tasks for bursty
# users. values map protocol label to delay. None means all protocols.
# https://github.com/snarfed/bridgy-fed/issues/1788
PER_USER_TASK_RATES = {
'receive': {
None: timedelta(seconds=5), # all protocols
},
'send': {
'atproto': timedelta(seconds=10),
},
}
class RateLimitType(Enum):
LINEAR = auto()
EXPONENTIAL = auto()
NDB_MEMCACHE_TIMEOUT = timedelta(hours=2)
WHITESPACE_RE = re.compile(f'[{string.whitespace}]')
# https://pymemcache.readthedocs.io/en/latest/apidoc/pymemcache.client.base.html#pymemcache.client.base.Client.__init__
kwargs = {
'server': os.environ.get('MEMCACHE_HOST', 'localhost'),
'allow_unicode_keys': True,
'default_noreply': False,
'timeout': 10, # seconds
'connect_timeout': 10, # seconds
}
if appengine_info.DEBUG or appengine_info.LOCAL_SERVER:
logger.info(f'Using in memory mock memcache: {kwargs}')
memcache = PooledClient(max_pool_size=1, **kwargs)
pickle_memcache = PooledClient(max_pool_size=1, serde=PickleSerde(), **kwargs)
memcache.client_class = pickle_memcache.client_class = MockMemcacheClient
global_cache = _InProcessGlobalCache()
else:
logger.info(f'Using production Memorystore memcache: {kwargs}')
memcache = PooledClient(**kwargs)
pickle_memcache = PooledClient(serde=PickleSerde(), **kwargs)
global_cache = MemcacheCache(memcache, strict_read=False, strict_write=False)
def cache_policy(key):
"""In memory ndb cache.
https://github.com/snarfed/bridgy-fed/issues/1149#issuecomment-2261383697
Only cache kinds in memory that are immutable or largely harmless when changed.
Keep an eye on this in case we start seeing problems due to this ndb bug
where unstored in-memory modifications get returned by later gets:
https://github.com/googleapis/python-ndb/issues/888
Args:
key (google.cloud.datastore.key.Key or google.cloud.ndb.key.Key):
see https://github.com/googleapis/python-ndb/issues/987
Returns:
bool: whether to cache this object
"""
if isinstance(key, Key):
# use internal google.cloud.datastore.key.Key
# https://github.com/googleapis/python-ndb/issues/987
key = key._key
return key and key.kind in ('AtpBlock', 'Object')
def global_cache_policy(key):
return True
def global_cache_timeout_policy(key):
"""Cache everything for 2h.
Args:
key (google.cloud.datastore.key.Key or google.cloud.ndb.key.Key):
see https://github.com/googleapis/python-ndb/issues/987
Returns:
int: cache expiration for this object, in seconds
"""
if isinstance(key, Key):
# use internal google.cloud.datastore.key.Key
# https://github.com/googleapis/python-ndb/issues/987
key = key._key
if key.kind == 'AtpBlock':
return None
return int(NDB_MEMCACHE_TIMEOUT.total_seconds())
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.
Args:
key (str)
Returns:
bytes:
"""
assert isinstance(key, str), repr(key)
return WHITESPACE_RE.sub('_', key).encode()[:KEY_MAX_LEN]
def memoize_key(fn, *args, _version=MEMOIZE_VERSION, **kwargs):
return key(f'{fn.__qualname__}-{_version}-{repr(args)}-{repr(kwargs)}')
NONE = () # empty tuple
def memoize(expire=None, key=None, write=True, version=MEMOIZE_VERSION):
"""Memoize function decorator that stores the cached value in memcache.
Args:
expire (datetime.timedelta): optional, expiration
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.
write (bool or callable): whether to write to memcache. If this is a
callable, it will be called with the function's ``(*args, **kwargs)``
and should return True or False.
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.
"""
expire = int(expire.total_seconds()) if expire else 0
def decorator(fn):
@functools.wraps(fn)
def wrapped(*args, **kwargs):
cache_key = None
if key:
key_val = key(*args, **kwargs)
if key_val:
cache_key = memoize_key(fn, key_val, _version=version)
else:
cache_key = memoize_key(fn, *args, _version=version, **kwargs)
if pickle_memcache and cache_key:
val = pickle_memcache.get(cache_key)
if val is not None:
logger.debug(f'cache hit {cache_key} {repr(val)[:100]}')
return None if val == NONE else val
else:
logger.debug(f'cache miss {cache_key}')
val = fn(*args, **kwargs)
if pickle_memcache and cache_key:
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)
return val
return wrapped
return decorator
def evict(entity_key):
"""Evict a datastore entity from memcache.
For :class:`models.User` and :class:`models.Object` entities, also clears their
copies from the :func:`models.get_original_user_key` and
:func:`models.get_original_object_key` memoize caches.
Args:
entity_key (google.cloud.ndb.Key)
"""
if entity := entity_key.get():
for val in getattr(entity, 'copies', []):
entity.clear_get_original_cache(val.uri)
global_cache.delete([global_cache_key(entity_key._key)])
def evict_raw(key):
"""Evict a key from memcache.
Args:
key (str)
Returns:
bool: whether the key existed and was deleted
"""
return memcache.delete(key)
def remote_evict(entity_key):
"""Send a request to production Bridgy Fed to evict an entity from memcache.
Args:
entity_key (google.cloud.ndb.Key)
Returns:
requests.Response:
"""
return util.requests_post(f'https://{PRIMARY_DOMAIN}/admin/memcache/evict',
headers={'Authorization': config.SECRET_KEY},
data={'key': entity_key.urlsafe()})
def task_eta(queue, user_id, protocol=None):
"""Get the ETA to use for a given user's task in a given queue.
Task rate limit delays are per user, stored in memcache with a key based on
``queue`` and ``user_id`` and an integer value of POSIX timestamp (UTC) in
seconds.
Only generates ETAs for task queues in :attr:`PER_USER_TASK_RATES`. Calls for
other queues always return ``None``.
Background: https://github.com/snarfed/bridgy-fed/issues/1788
Args:
queue (str)
user_id (str)
protocol (str): optional protocol label to look up protocol-specific delay
and :class:`RateLimitType`
Returns:
datetime.datetime: the ETA for this task, or ``None`` if the ETA is now
"""
from models import PROTOCOLS
if not (delays := PER_USER_TASK_RATES.get(queue)):
return None
# look up delay for protocol, fall back to None (all protocols)
if not (delay := delays.get(protocol) or delays.get(None)):
return None
cache_key = key(f'task-delay-{queue}-{user_id}')
now = util.now()
if protocol and PROTOCOLS[protocol].RATE_LIMIT_TYPE == RateLimitType.EXPONENTIAL:
if eta_s := memcache.get(cache_key):
eta = datetime.fromtimestamp(eta_s, timezone.utc)
if eta >= now:
cur_delay = eta - now
new_eta = eta + max(cur_delay, delay)
memcache.set(cache_key, int(new_eta.timestamp()))
return new_eta
else: # linear
if eta_s := memcache.incr(cache_key, int(delay.total_seconds())):
eta = datetime.fromtimestamp(eta_s, timezone.utc)
if eta > now:
return eta
# incr failed (key doesn't exist) or timestamp is in the past, set it to now
#
# note that this isn't synchronized; multiple callers may race and both get now
# as the returned ETA. that's ok, we don't depend on this for correctness in any
# way, just best-effort rate limiting.
memcache.set(cache_key, int(now.timestamp()))
return now
###########################################
# 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.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