blissdata 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. blissdata/__init__.py +17 -0
  2. blissdata/beacon/__init__.py +1 -0
  3. blissdata/beacon/_base.py +106 -0
  4. blissdata/beacon/config.py +24 -0
  5. blissdata/beacon/data.py +45 -0
  6. blissdata/beacon/files.py +141 -0
  7. blissdata/client.py +62 -0
  8. blissdata/common/__init__.py +15 -0
  9. blissdata/common/utils.py +159 -0
  10. blissdata/data/__init__.py +15 -0
  11. blissdata/data/events/__init__.py +22 -0
  12. blissdata/data/events/channel.py +149 -0
  13. blissdata/data/events/lima.py +478 -0
  14. blissdata/data/events/node.py +28 -0
  15. blissdata/data/events/scan.py +49 -0
  16. blissdata/data/events/walk.py +42 -0
  17. blissdata/data/expiration.py +42 -0
  18. blissdata/data/lima_image.py +465 -0
  19. blissdata/data/node.py +1619 -0
  20. blissdata/data/nodes/__init__.py +11 -0
  21. blissdata/data/nodes/channel.py +394 -0
  22. blissdata/data/nodes/dataset.py +82 -0
  23. blissdata/data/nodes/dataset_collection.py +12 -0
  24. blissdata/data/nodes/lima.py +422 -0
  25. blissdata/data/nodes/node_ref_channel.py +34 -0
  26. blissdata/data/nodes/proposal.py +12 -0
  27. blissdata/data/nodes/scan.py +192 -0
  28. blissdata/data/nodes/scan_group.py +12 -0
  29. blissdata/data/remote_node.py +204 -0
  30. blissdata/data/scan.py +666 -0
  31. blissdata/h5api/__init__.py +1 -0
  32. blissdata/h5api/abstract.py +97 -0
  33. blissdata/h5api/dynamic_hdf5.py +153 -0
  34. blissdata/h5api/file_arguments.py +28 -0
  35. blissdata/h5api/static_hdf5.py +139 -0
  36. blissdata/h5api/utils/__init__.py +0 -0
  37. blissdata/h5api/utils/bliss.py +138 -0
  38. blissdata/h5api/utils/hdf5.py +280 -0
  39. blissdata/h5api/utils/hdf5_retry.py +98 -0
  40. blissdata/h5api/utils/lima.py +286 -0
  41. blissdata/h5api/utils/types.py +13 -0
  42. blissdata/redis/__init__.py +12 -0
  43. blissdata/redis/caching.py +390 -0
  44. blissdata/redis/connection.py +169 -0
  45. blissdata/redis/manager.py +164 -0
  46. blissdata/redis/proxy.py +971 -0
  47. blissdata/redis/scripting.py +24 -0
  48. blissdata/settings.py +1174 -0
  49. blissdata/streaming.py +819 -0
  50. blissdata/streaming_events.py +355 -0
  51. blissdata/tests/__init__.py +0 -0
  52. blissdata/tests/beacon/__init__.py +0 -0
  53. blissdata/tests/beacon/test_config.py +26 -0
  54. blissdata/tests/beacon/test_data.py +50 -0
  55. blissdata/tests/beacon/test_files.py +80 -0
  56. blissdata/tests/conftest.py +0 -0
  57. blissdata/tests/h5api/__init__.py +0 -0
  58. blissdata/tests/h5api/scanner.py +390 -0
  59. blissdata/tests/h5api/test_dynamic_files.py +309 -0
  60. blissdata/tests/h5api/test_static_files.py +169 -0
  61. blissdata/tests/redis/__init__.py +0 -0
  62. blissdata/tests/redis/manager.py +23 -0
  63. blissdata-0.3.4.dist-info/LICENSE +165 -0
  64. blissdata-0.3.4.dist-info/METADATA +79 -0
  65. blissdata-0.3.4.dist-info/RECORD +67 -0
  66. blissdata-0.3.4.dist-info/WHEEL +5 -0
  67. blissdata-0.3.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,971 @@
1
+ """Redis database proxies.
2
+ """
3
+
4
+ import sys
5
+ import enum
6
+ import weakref
7
+ import fnmatch
8
+ import time
9
+ from functools import wraps
10
+ from collections.abc import MutableMapping
11
+ from collections import Counter
12
+ from contextlib import contextmanager
13
+ import gevent
14
+ import gevent.event
15
+ import gevent.lock
16
+ import redis
17
+ import redis.client
18
+
19
+ from blissdata.redis.caching import RedisCacheError
20
+ from blissdata.redis.caching import RedisCache
21
+ from blissdata.common.utils import grouped
22
+
23
+
24
+ def key_value_list_or_mapping_to_dict(key_value_list_or_mapping):
25
+ """Helper function to convert a list key1,value1, ..., keyN, valueN to a dict,
26
+ or to return a mapping as it is
27
+ """
28
+ if len(key_value_list_or_mapping) == 1:
29
+ # must be a mapping
30
+ assert isinstance(key_value_list_or_mapping[0], MutableMapping)
31
+ mapping = key_value_list_or_mapping[0]
32
+ else:
33
+ # function parameters as key1, value1, ..., keyN, valueN
34
+ mapping = dict(grouped(key_value_list_or_mapping, 2))
35
+ return mapping
36
+
37
+
38
+ class AsyncRedisDbProxy(redis.client.Pipeline):
39
+ """All redis commands are executed asynchronously. Call `execute`
40
+ to execute the commands or `reset` to cancel them.
41
+
42
+ As this takes a fixed connection from the connection pool, it
43
+ is not greenlet-safe.
44
+
45
+ Reponse callbacks are applied to the result of a certain command.
46
+ Execute callbacks are called upon execution of the pipeline.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ connection_pool=None,
52
+ response_callbacks=None,
53
+ transaction=True,
54
+ shard_hint=None,
55
+ ):
56
+ if response_callbacks is None:
57
+ response_callbacks = dict()
58
+ if connection_pool is None:
59
+ raise ValueError("connection_pool not provided")
60
+ self._execute_callbacks = list()
61
+ super().__init__(connection_pool, response_callbacks, transaction, shard_hint)
62
+
63
+ def pipeline(self, **kw):
64
+ kw.setdefault("connection_pool", self.connection_pool)
65
+ kw.setdefault("response_callbacks", self.response_callbacks)
66
+ # Do not use `super` here or you get an instance of the base class
67
+ return self.__class__(**kw)
68
+
69
+ def reset(self):
70
+ super().reset()
71
+ self._execute_callbacks = list()
72
+
73
+ def execute(self, **kw):
74
+ callbacks = self._execute_callbacks
75
+ result = super().execute(**kw)
76
+ for func, args, kwargs in callbacks:
77
+ func(*args, **kwargs)
78
+ return result
79
+
80
+ def add_execute_callback(self, func, *args, **kw):
81
+ self._execute_callbacks.append((func, args, kw))
82
+
83
+
84
+ class MonitoringAsyncRedisDbProxy(AsyncRedisDbProxy):
85
+ """An asynchronous Redis proxy which monitors time, total buffered
86
+ command size and events per stream.
87
+
88
+ Use `wait_maximum_reached` to wait until one of the monitored resources
89
+ hit their maximum.
90
+
91
+ Use `maximum_is_reached` to manually say the maximum is reached.
92
+ """
93
+
94
+ def __init__(self, **kw):
95
+ """
96
+ :param int or None max_stream_events: maximum event per stream
97
+ :param int or None max_bytes: maximum on the buffered commands
98
+ :param num or None max_time: maximum on time since first buffered command
99
+ """
100
+ self._max_stream_events = kw.pop("max_stream_events", None)
101
+ self._max_bytes = kw.pop("max_bytes", None)
102
+ self._max_time = kw.pop("max_time", None)
103
+ self._max_event = gevent.event.Event()
104
+ self._reset_monitoring()
105
+ super().__init__(**kw)
106
+
107
+ def wait_maximum_reached(self):
108
+ """Wait until any of the resouce maxima is reached"""
109
+ return self._max_event.wait(timeout=self._time_left)
110
+
111
+ def maximum_is_reached(self):
112
+ """Causes an existing or future `wait_maximum_reached` call to
113
+ return immediately (until reset).
114
+ """
115
+ self._max_event.set()
116
+
117
+ def _reset_monitoring(self):
118
+ """Restart resource monitoring"""
119
+ self._nbytes = 0
120
+ self._nstream_events = Counter()
121
+ self._start_time = None
122
+ self._max_event.clear()
123
+ if (
124
+ self._max_stream_events is None
125
+ and self._max_bytes is None
126
+ and self._max_time is None
127
+ ):
128
+ self._max_event.set()
129
+
130
+ def reset(self):
131
+ super().reset()
132
+ self._reset_monitoring()
133
+
134
+ def xadd(self, name, fields, **kw):
135
+ super().xadd(name, fields, **kw)
136
+ self._monitor_stream_events(name)
137
+
138
+ def pipeline_execute_command(self, *args, **options):
139
+ super().pipeline_execute_command(*args, **options)
140
+ self._monitor_time()
141
+ self._monitor_data_size(args)
142
+
143
+ def _monitor_stream_events(self, name):
144
+ """Increase the stream counter and check maximum"""
145
+ if self._max_event.is_set() or self._max_stream_events is None:
146
+ return
147
+ self._nstream_events[name] += 1
148
+ if self._nstream_events[name] >= self._max_stream_events:
149
+ self.maximum_is_reached()
150
+
151
+ def _monitor_data_size(self, data):
152
+ """Increase the total buffered command size and check maximum"""
153
+ if self._max_event.is_set() or self._max_bytes is None:
154
+ return
155
+ self._nbytes += sys.getsizeof(data)
156
+ if self._nbytes >= self._max_bytes:
157
+ self.maximum_is_reached()
158
+
159
+ def _monitor_time(self):
160
+ """Start timing when needed and check time maximum"""
161
+ if self._time_left == 0:
162
+ self.maximum_is_reached()
163
+
164
+ @property
165
+ def _time_left(self):
166
+ """Returns `None` when not monitoring time or some maximum is
167
+ already reached.
168
+ """
169
+ if self._max_event.is_set() or self._max_time is None:
170
+ return None
171
+ if self._start_time is None:
172
+ self._start_time = time.perf_counter()
173
+ return max(self._max_time - (time.perf_counter() - self._start_time), 0)
174
+
175
+ def pipeline(self, **kw):
176
+ kw.setdefault("max_stream_events", self._max_stream_events)
177
+ kw.setdefault("max_bytes", self._max_bytes)
178
+ kw.setdefault("max_time", self._max_time)
179
+ return super().pipeline(**kw)
180
+
181
+
182
+ class MonitoringAsyncRedisDbProxyManager:
183
+ """Rotate asynchronous Redis proxies and execute in the background
184
+ upon rotation. Usage:
185
+
186
+ mgr = proxy.rotating_pipeline()
187
+ with mgr.async_proxy() as async_proxy:
188
+ async_proxy.set("key1", "value1")
189
+ async_proxy.xadd("key2", {"key": "value2"})
190
+
191
+ with mgr.async_proxy() as async_proxy:
192
+ async_proxy.set("key3", "value3")
193
+ async_proxy.xadd("key4", {"key": "value4"})
194
+ async_proxy.add_execute_callback(func, "var1", kwarg1="kwarg1")
195
+
196
+ mgr.flush() # to force a pipeline execution
197
+
198
+ The underlying pipeline is rotated and executed when `flush` is called
199
+ or when the pipeline has reached its rotating criterea (see MonitoringAsyncRedisDbProxy).
200
+ """
201
+
202
+ def __init__(self, async_proxy: MonitoringAsyncRedisDbProxy):
203
+ self._task = None
204
+ self._async_proxy = async_proxy
205
+ # protect the rotation of _async_proxy
206
+ self._rotating_lock = gevent.lock.RLock()
207
+
208
+ @property
209
+ def _pipeline_is_empty(self) -> bool:
210
+ """Returns True when no commands are buffered in the pipeline."""
211
+ return not len(self._async_proxy)
212
+
213
+ @property
214
+ def _inside_task(self) -> bool:
215
+ """Returns True when the current greenlet is the task greenlet"""
216
+ return gevent.getcurrent() is self._task
217
+
218
+ @contextmanager
219
+ def async_proxy(self):
220
+ """Context to add commands to the pipeline."""
221
+ with self._rotating_lock:
222
+ async_proxy = self._async_proxy
223
+ yield async_proxy
224
+ if self._inside_task:
225
+ if len(async_proxy):
226
+ async_proxy.execute()
227
+ else:
228
+ self._ensure_task()
229
+
230
+ def flush(self, blocking=True, timeout=None, raise_error=False) -> bool:
231
+ """Flush all commands in the current pipeline. Returns `True` when flush completed."""
232
+ with self._rotating_lock:
233
+ if self._pipeline_is_empty:
234
+ return True
235
+ if self._inside_task:
236
+ self._async_proxy.execute()
237
+ return True
238
+ task = self._ensure_task()
239
+ # Set the rotation event on which the task is or will be waiting
240
+ self._async_proxy.maximum_is_reached()
241
+
242
+ if blocking and task is not None:
243
+ # Note that while we wait, another `flush` or
244
+ # `async_proxy` call can be executed.
245
+ try:
246
+ with gevent.Timeout(timeout) as local_timeout:
247
+ if raise_error:
248
+ task.get()
249
+ else:
250
+ task.join()
251
+ except gevent.Timeout as raised_timeout:
252
+ if local_timeout is not raised_timeout:
253
+ raise
254
+ return not bool(task)
255
+
256
+ def _ensure_task(self):
257
+ """Ensure the execute and rotate task is running when the
258
+ pipeline contains commands.
259
+ """
260
+ # Return when task is already running or no commands in the pipeline
261
+ if self._task or self._pipeline_is_empty:
262
+ return self._task
263
+
264
+ # Raise error when the previous task failed
265
+ if self._task is not None:
266
+ self._task.get()
267
+
268
+ # Launch execute and rotate task
269
+ self._task = gevent.spawn(self._execute_and_rotate_on_maximum)
270
+ return self._task
271
+
272
+ def _execute_and_rotate_on_maximum(self):
273
+ """Wait for the rotation event and then execute+rotate the pipeline.
274
+ When the pipeline is empty: return immediately.
275
+ """
276
+ if self._pipeline_is_empty:
277
+ return
278
+ self._async_proxy.wait_maximum_reached()
279
+ with self._rotating_lock:
280
+ async_proxy = self._async_proxy
281
+ self._async_proxy = self._async_proxy.pipeline()
282
+ # As a result of add_execute_callback, execute could cause
283
+ # a call to async_proxy or flush.
284
+ async_proxy.execute()
285
+
286
+
287
+ class RedisDbProxyBase(redis.Redis):
288
+ """A proxy to a particular Redis server and database as determined
289
+ by the connection pool.
290
+
291
+ The `close` method, called upon garbage collection, will return any
292
+ `redis.connection.Connection` instance this proxy may have back to
293
+ its connection pool. This does not close the connection.
294
+
295
+ The method `pipeline` returns a `redis.client.Pipeline` instance which
296
+ behaves like a proxy, except that all `execute_command` calls are buffered
297
+ until `execute` is called. It also works as a context manager: calls
298
+ `execute` on finalization.
299
+
300
+ The `AsyncRedisDbProxy` instance uses the same connection pool.
301
+ The `AsyncRedisDbProxy` instance is NOT greenlet-safe.
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ connection_pool=None,
307
+ async_class=AsyncRedisDbProxy,
308
+ monitoring_async_class=MonitoringAsyncRedisDbProxy,
309
+ **kw,
310
+ ):
311
+ self._async_class = async_class
312
+ self._monitoring_async_class = monitoring_async_class
313
+ if connection_pool is None:
314
+ raise ValueError("connection_pool not provided")
315
+ super().__init__(connection_pool=connection_pool, **kw)
316
+
317
+ def hset(self, name, *key_value_list_or_mapping, mapping=None):
318
+ """hset method, compatible with deprecated hmset
319
+
320
+ If mapping is given: just call redis.Redis "hset"
321
+ If not mapping is given, individual key, value args can be passed,
322
+ or a dictionary
323
+ """
324
+ if mapping is None:
325
+ mapping = key_value_list_or_mapping_to_dict(key_value_list_or_mapping)
326
+
327
+ return super().hset(name, mapping=mapping)
328
+
329
+ def pipeline(self, **kw):
330
+ kw.setdefault("connection_pool", self.connection_pool)
331
+ kw.setdefault("response_callbacks", self.response_callbacks)
332
+ return self._async_class(**kw)
333
+
334
+ def rotating_pipeline(self, **kw):
335
+ kw.setdefault("connection_pool", self.connection_pool)
336
+ kw.setdefault("response_callbacks", self.response_callbacks)
337
+ async_proxy = self._monitoring_async_class(**kw)
338
+ return MonitoringAsyncRedisDbProxyManager(async_proxy)
339
+
340
+
341
+ class RedisDbProxy(RedisDbProxyBase):
342
+ """It gets a Connection from the pool every time it executes a command.
343
+
344
+ This proxy is greenlet-safe although the connections are not.
345
+ """
346
+
347
+ def __init__(self, *args, **kw):
348
+ kw["single_connection_client"] = False
349
+ super().__init__(*args, **kw)
350
+
351
+
352
+ class SingleConnectionRedisDbProxy(RedisDbProxyBase):
353
+ """It gets a connection from the pool during instantiation and holds that
354
+ connection until `close` is called (called upon garbage collection).
355
+
356
+ This proxy is not greenlet-safe.
357
+
358
+ The `AsyncRedisDbProxy` creates new connections.
359
+ """
360
+
361
+ def __init__(self, *args, **kw):
362
+ kw["single_connection_client"] = True
363
+ super().__init__(*args, **kw)
364
+
365
+
366
+ class CachingAsyncRedisDbProxy(AsyncRedisDbProxy):
367
+ """Handle cache manipulation asynchronously, just like the Redis
368
+ commands (i.e. execute upon calling `execute` and drop upon `reset`).
369
+ """
370
+
371
+ def __init__(self, root_proxy=None, **kw):
372
+ self._root_proxy = root_proxy
373
+ self._db_cache_tasks = list()
374
+ self._connection = None
375
+ super().__init__(**kw)
376
+
377
+ def pipeline(self, **kw):
378
+ kw.setdefault("root_proxy", self._root_proxy)
379
+ return super().pipeline(**kw)
380
+
381
+ @property
382
+ def connection(self):
383
+ if self._connection is None:
384
+ return self._root_proxy.connection
385
+ else:
386
+ return self._connection
387
+
388
+ @connection.setter
389
+ def connection(self, value):
390
+ self._connection = value
391
+
392
+ @property
393
+ def _caching_lock(self):
394
+ return self._root_proxy._caching_lock
395
+
396
+ @property
397
+ def db_cache(self):
398
+ return self._root_proxy.db_cache
399
+
400
+ @property
401
+ def _enable_caching(self):
402
+ return self._root_proxy._enable_caching
403
+
404
+ @property
405
+ def _use_caching(self):
406
+ return self._enable_caching and self._db_cache_tasks
407
+
408
+ def reset(self):
409
+ super().reset()
410
+ self._db_cache_tasks.clear()
411
+
412
+ def delete(self, name):
413
+ def delete_func():
414
+ self.db_cache.pop(name, None)
415
+
416
+ self._db_cache_tasks.append(delete_func)
417
+ return super().delete(name)
418
+
419
+ def set(self, name, value, *args, **kwargs):
420
+ def set_func():
421
+ self.db_cache[name] = value
422
+
423
+ self._db_cache_tasks.append(set_func)
424
+ return super().set(name, value, *args, **kwargs)
425
+
426
+ def hdel(self, name, *keys):
427
+ def hdel_func():
428
+ cached_dict = self.db_cache.get(name)
429
+ if cached_dict is not None:
430
+ for k in keys:
431
+ cached_dict.pop(k.encode(), None)
432
+
433
+ self._db_cache_tasks.append(hdel_func)
434
+ return super().hdel(name, *keys)
435
+
436
+ def hset(self, name, *key_value_list_or_mapping, mapping=None):
437
+ if mapping is None:
438
+ mapping = key_value_list_or_mapping_to_dict(key_value_list_or_mapping)
439
+
440
+ def hset_func():
441
+ cached_dict = self.db_cache.get(name)
442
+ if cached_dict is not None:
443
+ for key, value in mapping.items():
444
+ cached_dict[key.encode()] = value
445
+
446
+ self._db_cache_tasks.append(hset_func)
447
+ return super().hset(name, mapping=mapping)
448
+
449
+ def lpop(self, name, *values):
450
+ def lpop_func():
451
+ cached_list = self.db_cache.get(name)
452
+ if cached_list is not None:
453
+ try:
454
+ cached_list.pop(0)
455
+ except IndexError:
456
+ pass
457
+
458
+ self._db_cache_tasks.append(lpop_func)
459
+ return super().lpop(name, *values)
460
+
461
+ def lpush(self, name, *values):
462
+ def lpush_func():
463
+ cache_list = self.db_cache.get(name)
464
+ if cache_list is not None:
465
+ for v in values:
466
+ cache_list.insert(0, v)
467
+
468
+ self._db_cache_tasks.append(lpush_func)
469
+ return super().lpush(name, *values)
470
+
471
+ def rpush(self, name, *values):
472
+ def rpush_func():
473
+ cache_list = self.db_cache.get(name)
474
+ if cache_list is not None:
475
+ cache_list.extend(values)
476
+
477
+ self._db_cache_tasks.append(rpush_func)
478
+ return super().rpush(name, *values)
479
+
480
+ def rpop(self, name):
481
+ def rpop_func():
482
+ cache_list = self.db_cache.get(name)
483
+ if cache_list is not None:
484
+ try:
485
+ cache_list.pop(-1)
486
+ except IndexError:
487
+ pass
488
+
489
+ self._db_cache_tasks.append(rpop_func)
490
+ return super().rpop(name)
491
+
492
+ def lrem(self, name, *args):
493
+ def clear_cache():
494
+ self.db_cache.pop(name, None)
495
+
496
+ self._db_cache_tasks.append(clear_cache)
497
+ return super().lrem(name, *args)
498
+
499
+ def _execute_cache_tasks(self):
500
+ for task in self._db_cache_tasks:
501
+ task()
502
+ self._db_cache_tasks.clear()
503
+
504
+ def immediate_execute_command(self, *args, **kw):
505
+ if self._use_caching:
506
+ with self._caching_lock:
507
+ self._execute_cache_tasks()
508
+ return super().immediate_execute_command(*args, **kw)
509
+ else:
510
+ return super().immediate_execute_command(*args, **kw)
511
+
512
+ def execute(self, **kw):
513
+ if self._use_caching:
514
+ with self._caching_lock:
515
+ self._execute_cache_tasks()
516
+ return super().execute(**kw)
517
+ else:
518
+ return super().execute(**kw)
519
+
520
+
521
+ class MonitoringCachingAsyncRedisDbProxy(
522
+ CachingAsyncRedisDbProxy, MonitoringAsyncRedisDbProxy
523
+ ):
524
+ pass
525
+
526
+
527
+ class CachedSettingsDict(MutableMapping):
528
+ """Dictionary of BaseSettings that are cached. This is used so that
529
+ the associated Redis keys are removed from the cache when the setting
530
+ is removed from CachedSettingsDict.
531
+ """
532
+
533
+ def __init__(self, db_cache):
534
+ self._cached_settings = weakref.WeakKeyDictionary()
535
+ self.db_cache = db_cache
536
+
537
+ def __setitem__(self, key, value):
538
+ self._cached_settings[key] = value
539
+
540
+ def __getitem__(self, key):
541
+ return self._cached_settings[key]
542
+
543
+ def __delitem__(self, key):
544
+ name, _ = self._cached_settings[key]
545
+ del self._cached_settings[key]
546
+ try:
547
+ self.db_cache.pop(name, None)
548
+ except RedisCacheError:
549
+ pass # Not cached anyway
550
+
551
+ def __iter__(self):
552
+ return iter(self._cached_settings)
553
+
554
+ def __len__(self):
555
+ return len(self._cached_settings)
556
+
557
+
558
+ def lock_proxy(method):
559
+ """To protect the proxy from concurrent access."""
560
+
561
+ @wraps(method)
562
+ def _lock_proxy(self, *args, **kwargs):
563
+ with self._caching_lock:
564
+ return method(self, *args, **kwargs)
565
+
566
+ return _lock_proxy
567
+
568
+
569
+ def assert_tracking(method):
570
+ """Raise exception when we cannot use the tracking connection."""
571
+
572
+ @wraps(method)
573
+ def _assert_tracking(self, *args, **kwargs):
574
+ if not self._use_tracking_connection():
575
+ raise RedisCacheError(
576
+ f"{repr(method)} can only be executed when we are allowed to use the tracking connection"
577
+ )
578
+ return method(self, *args, **kwargs)
579
+
580
+ return _assert_tracking
581
+
582
+
583
+ def caching_command(method):
584
+ """This waits until we are allowed to use the tracking connection
585
+ and prevents other greenlets from using that connection until we
586
+ are done with it.
587
+
588
+ When caching is disabled however, we will not use the tracking
589
+ connection (in fact their is None). As a result we will executed
590
+ the Redis command non-cached over a Connection it gets from the
591
+ Redis connection pool.
592
+ """
593
+
594
+ @wraps(method)
595
+ def _caching_command(self, *args, **kw):
596
+ if self._use_caching:
597
+ with self._caching_lock:
598
+ return method(self, *args, **kw)
599
+ else:
600
+ # WARNING: this only works for the current inheritance structure.
601
+ # If you derive a class from CachingRedisDbProxy it will not work.
602
+ parent_method = getattr(super(CachingRedisDbProxy, self), method.__name__)
603
+ return parent_method(*args, **kw)
604
+
605
+ return _caching_command
606
+
607
+
608
+ class CachingRedisDbProxy(RedisDbProxyBase):
609
+ """Setting/getting Redis keys through this proxy will cache them in
610
+ the connection pool's cache.
611
+
612
+ When settings are registered with `add_prefetch`, they will be fetched
613
+ when they are missing from the cache. It is not required to have those
614
+ settings cached however. It just refetches whenever invalidated due
615
+ to changes in the Redis database.
616
+
617
+ This could be done only if Redis version >= 6.
618
+ """
619
+
620
+ TYPE = enum.Enum("TYPE", "HASH KEY QUEUE ZSET")
621
+
622
+ def __init__(self, connection_pool=None, **kw):
623
+ self.db_cache = RedisCache(connection_pool=connection_pool)
624
+ self._caching_lock = gevent.lock.RLock()
625
+ self._enable_caching = True
626
+ self._cached_settings = CachedSettingsDict(self.db_cache)
627
+ kw.setdefault("async_class", CachingAsyncRedisDbProxy)
628
+ kw.setdefault("monitoring_async_class", MonitoringCachingAsyncRedisDbProxy)
629
+ super().__init__(connection_pool=connection_pool, **kw)
630
+ self.enable_caching()
631
+
632
+ def pipeline(self, **kw):
633
+ kw.setdefault("root_proxy", self)
634
+ return super().pipeline(**kw)
635
+
636
+ def rotating_pipeline(self, **kw):
637
+ kw.setdefault("root_proxy", self)
638
+ return super().rotating_pipeline(**kw)
639
+
640
+ def _use_tracking_connection(self):
641
+ return self._caching_lock._is_owned()
642
+
643
+ @property
644
+ def connection(self):
645
+ """Use the caching connection when RedisCache is connected and
646
+ when we hold the proxy lock.
647
+ """
648
+ if self._use_tracking_connection():
649
+ # Returns None when cache is disabled
650
+ return self.db_cache.connection
651
+ else:
652
+ # A connection will be taken from the pool
653
+ return None
654
+
655
+ @connection.setter
656
+ def connection(self, value):
657
+ # This proxy should never own a connection
658
+ pass
659
+
660
+ def close(self):
661
+ self.disable_caching()
662
+
663
+ @property
664
+ def _use_caching(self):
665
+ return self._enable_caching
666
+
667
+ @lock_proxy
668
+ def disable_caching(self):
669
+ """After this command, the proxy behaves like `RedisDbProxy`.
670
+ All pre-fetched objects will be removed.
671
+ """
672
+ self._enable_caching = False
673
+ self.clear_all_prefetch()
674
+ self.db_cache.disconnect()
675
+
676
+ @lock_proxy
677
+ def enable_caching(self):
678
+ self._enable_caching = True
679
+ self.db_cache.connect()
680
+
681
+ @lock_proxy
682
+ def add_prefetch(self, *objects):
683
+ """Adds object to be pre-fetched in block in case of any cache failed.
684
+ Objects will be always kept in memory even if they are not accessed.
685
+ Redis communication happens in the method so not not execute in
686
+ a pipeline.
687
+
688
+ Any setting can be added to be pre-fetched.
689
+ """
690
+ from blissdata import settings
691
+
692
+ for obj in objects:
693
+ name = obj.name
694
+ if not isinstance(name, (str, bytes)):
695
+ raise TypeError(
696
+ f"Cannot prefetch {obj} inside an asynchronous Redis pipeline"
697
+ )
698
+ if isinstance(obj, settings.SimpleSetting):
699
+ self._cached_settings[obj] = (name, self.TYPE.KEY)
700
+ elif isinstance(obj, settings.BaseHashSetting):
701
+ self._cached_settings[obj] = (name, self.TYPE.HASH)
702
+ elif isinstance(obj, settings.Struct):
703
+ self._cached_settings[obj._proxy] = (name, self.TYPE.HASH)
704
+ else:
705
+ raise ValueError(f"Type not yet managed {obj}")
706
+
707
+ @lock_proxy
708
+ def remove_prefetch(self, *objects):
709
+ for obj in objects:
710
+ self._cached_settings.pop(obj, None)
711
+
712
+ @lock_proxy
713
+ def clear_all_prefetch(self):
714
+ self._cached_settings.clear()
715
+
716
+ @caching_command
717
+ def evalsha(self, script_name, n, *args):
718
+ keys = args[:n]
719
+ super().evalsha(script_name, n, *args)
720
+ # invalidate cache for those keys
721
+ for k in keys:
722
+ self.db_cache.pop(k, None)
723
+
724
+ @caching_command
725
+ def delete(self, name):
726
+ self.db_cache.pop(name, None)
727
+ super().delete(name)
728
+
729
+ # KEY
730
+ @caching_command
731
+ def get(self, name):
732
+ return self._get_cache_key(name)
733
+
734
+ @caching_command
735
+ def set(self, name, value, ex=None, px=None, nx=False, xx=False):
736
+ return_val = super().set(name, value, ex, px, nx, xx)
737
+ self.db_cache[name] = value
738
+ return return_val
739
+
740
+ @caching_command
741
+ def testincr(self, name, amount=1):
742
+ # Just for concurrancy testing. The real incr needs to be atomic
743
+ # so we can't do it through the cache.
744
+ result = super().incr(name, amount=amount)
745
+ self.set(name, b"%d" % result)
746
+ return result
747
+
748
+ # HASH
749
+ @caching_command
750
+ def hdel(self, name, *keys):
751
+ return_val = super().hdel(name, *keys)
752
+ cached_dict = self._get_cache_dict(name)
753
+ for k in keys:
754
+ cached_dict.pop(k.encode(), None)
755
+ return return_val
756
+
757
+ @caching_command
758
+ def hexists(self, name, key):
759
+ cached_dict = self._get_cache_dict(name)
760
+ return key.encode() in cached_dict
761
+
762
+ @caching_command
763
+ def hget(self, name, key):
764
+ cached_dict = self._get_cache_dict(name)
765
+ return cached_dict.get(key.encode() if isinstance(key, str) else key)
766
+
767
+ @caching_command
768
+ def hgetall(self, name):
769
+ return self._get_cache_dict(name)
770
+
771
+ @caching_command
772
+ def hlen(self, name):
773
+ cached_dict = self._get_cache_dict(name)
774
+ return len(cached_dict)
775
+
776
+ @caching_command
777
+ def hset(self, name, *key_value_list_or_mapping, mapping=None):
778
+ if mapping is None:
779
+ mapping = key_value_list_or_mapping_to_dict(key_value_list_or_mapping)
780
+ cached_dict = self._get_cache_dict(name)
781
+ for key, value in mapping.items():
782
+ cached_dict[key.encode()] = value
783
+
784
+ return super().hset(name, mapping=mapping)
785
+
786
+ @caching_command
787
+ def hscan(self, name, cursor=0, match=None, count=None):
788
+ cached_dict = self._get_cache_dict(name)
789
+ if count is None or count >= len(cached_dict):
790
+ if match is None:
791
+ return 0, cached_dict
792
+ else:
793
+ return (
794
+ 0,
795
+ {
796
+ k: v
797
+ for k, v in cached_dict.items()
798
+ if fnmatch.fnmatch(k.decode(), match)
799
+ },
800
+ )
801
+ else:
802
+ # This part is really not optimize but it's not use
803
+ # in the base code.
804
+ key_list = list(cached_dict.keys())
805
+ return_dict = dict()
806
+ index = cursor
807
+ while count:
808
+ try:
809
+ key = key_list[index]
810
+ except IndexError:
811
+ return 0, return_dict
812
+ index += 1
813
+ if match is not None and not fnmatch.fnmatch(key.decode(), match):
814
+ continue
815
+ return_dict[key] = cached_dict[key]
816
+ count -= 1
817
+ return index, return_dict
818
+
819
+ # LIST COMMANDS
820
+ @caching_command
821
+ def lindex(self, name, index):
822
+ cache_list = self._get_cache_list(name)
823
+ return cache_list[index]
824
+
825
+ @caching_command
826
+ def llen(self, name):
827
+ cache_list = self._get_cache_list(name)
828
+ return len(cache_list)
829
+
830
+ @caching_command
831
+ def lpop(self, name):
832
+ return_val = super().lpop(name)
833
+ cache_list = self._get_cache_list(name)
834
+ if cache_list and return_val == cache_list[0]:
835
+ cache_list.pop(0)
836
+ return return_val
837
+
838
+ @caching_command
839
+ def lpush(self, name, *values):
840
+ return_val = super().lpush(name, *values)
841
+ cache_list = self._get_cache_list(name)
842
+ for v in values:
843
+ cache_list.insert(0, v)
844
+ return return_val
845
+
846
+ @caching_command
847
+ def rpush(self, name, *values):
848
+ return_val = super().lpush(name, *values)
849
+ cache_list = self._get_cache_list(name)
850
+ cache_list.extend(values)
851
+ return return_val
852
+
853
+ @caching_command
854
+ def lrange(self, name, start, end):
855
+ cache_list = self._get_cache_list(name)
856
+ if end == -1:
857
+ end = len(cache_list)
858
+ else:
859
+ end += 1
860
+ return cache_list[start:end]
861
+
862
+ @caching_command
863
+ def rpop(self, name):
864
+ return_val = super().rpop(name)
865
+ cache_list = self._get_cache_list(name)
866
+ if cache_list and return_val == cache_list[-1]:
867
+ cache_list.pop(-1)
868
+ return return_val
869
+
870
+ @caching_command
871
+ def lrem(self, name, count, value):
872
+ return_val = super().lrem(name, count, value)
873
+ if count >= 0:
874
+ cache_list = self._get_cache_list(name)
875
+ for i in range(return_val):
876
+ try:
877
+ cache_list.remove(value)
878
+ except ValueError: # already removed
879
+ pass
880
+ else: # re-synchronization on next get
881
+ self.db_cache.pop(name, None)
882
+
883
+ # SORTED SET COMMANDS
884
+ @caching_command
885
+ def zrange(
886
+ self, name, start, end, desc=False, withscores=False, score_cast_func=float
887
+ ):
888
+ cache_sorted_set = self._get_cache_sorted_set(name)
889
+ if end == -1:
890
+ end = len(cache_sorted_set)
891
+ else:
892
+ end += 1
893
+ if desc:
894
+ items = reversed(cache_sorted_set.items())
895
+ else:
896
+ items = cache_sorted_set.items()
897
+
898
+ if withscores:
899
+ return list(items[start:end])
900
+ return list(x for x, y in items)
901
+
902
+ @assert_tracking
903
+ def _get_cache_dict(self, name):
904
+ cached_dict = self.db_cache.get(name)
905
+ if cached_dict is None:
906
+ cached_dict = self._fill_cache(name, self.TYPE.HASH)
907
+ return cached_dict
908
+
909
+ @assert_tracking
910
+ def _get_cache_key(self, name):
911
+ value = self.db_cache.get(name)
912
+ if value is None and name not in self.db_cache:
913
+ value = self._fill_cache(name, self.TYPE.KEY)
914
+ return value
915
+
916
+ @assert_tracking
917
+ def _get_cache_list(self, name):
918
+ values = self.db_cache.get(name)
919
+ if values is None:
920
+ values = self._fill_cache(name, self.TYPE.QUEUE)
921
+ return values
922
+
923
+ @assert_tracking
924
+ def _get_cache_sorted_set(self, name):
925
+ values = self.db_cache.get(name)
926
+ if values is None:
927
+ # return a list with name and score
928
+ # change it to dict
929
+ values = dict(self._fill_cache(name, self.TYPE.ZSET))
930
+ self.db_cache[name] = values
931
+ return values
932
+
933
+ @assert_tracking
934
+ def _fill_cache(self, name, object_type):
935
+ """This method gets the value of Redis key "name" from the Redis
936
+ database and caches it. In addition we will fetch all the
937
+ "prefetch" keys which have not been cached yet (or were invalidated).
938
+
939
+ :param str name: Redis key name
940
+ :param TYPE object_type: Redis value type
941
+ :returns: the value of the Redis key
942
+ """
943
+ cached_settings = {name: object_type}
944
+ fetch_names = {name}
945
+
946
+ # Add prefetch objects that are currently not cached
947
+ cached_settings.update(
948
+ {name: obj_type for name, obj_type in self._cached_settings.values()}
949
+ )
950
+ fetch_names.update(cached_settings.keys() - self.db_cache.keys())
951
+
952
+ # Fetch all values from Redis
953
+ pipeline = self.pipeline()
954
+ for obj_name in fetch_names:
955
+ obj_type = cached_settings[obj_name]
956
+ if obj_type == self.TYPE.HASH:
957
+ pipeline.hgetall(obj_name)
958
+ elif obj_type == self.TYPE.KEY:
959
+ pipeline.get(obj_name)
960
+ elif obj_type == self.TYPE.QUEUE:
961
+ pipeline.lrange(obj_name, 0, -1)
962
+ elif obj_type == self.TYPE.ZSET:
963
+ pipeline.zrange(obj_name, 0, -1, withscores=True)
964
+ pipeline_result = pipeline.execute()
965
+
966
+ # Fill the cache with those values
967
+ for obj_name, result in zip(fetch_names, pipeline_result):
968
+ self.db_cache[obj_name] = result
969
+
970
+ # Return the value of the key we actually asked for
971
+ return self.db_cache[name]