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
blissdata/streaming.py ADDED
@@ -0,0 +1,819 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # This file is part of the bliss project
4
+ #
5
+ # Copyright (c) 2015-2023 Beamline Control Unit, ESRF
6
+ # Distributed under the GNU LGPLv3. See LICENSE for more info.
7
+
8
+ import sys
9
+ import gevent
10
+ import gevent.event
11
+ import gevent.queue
12
+ import uuid
13
+ import enum
14
+ import fnmatch
15
+ import time
16
+ import logging
17
+ from contextlib import contextmanager
18
+ from blissdata.settings import BaseSetting, pipeline
19
+ from blissdata import streaming_events
20
+ from blissdata.redis.scripting import register_script, evaluate_script
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class CustomLogger(logging.LoggerAdapter):
26
+ def process(self, msg, kwargs):
27
+ return "[{}] {}".format(str(self.extra), msg), kwargs
28
+
29
+
30
+ create_stream_script = """
31
+ -- Atomic creation of an empty STREAM in Redis
32
+
33
+ -- KEYS[1]: redis-key of the STREAM
34
+
35
+ local streamkey = KEYS[1]
36
+
37
+ if (redis.call("EXISTS", streamkey)==0) then
38
+ redis.call("XADD", streamkey, "0-1", "key", "value")
39
+ redis.call("XDEL", streamkey, "0-1")
40
+ end
41
+ """
42
+
43
+
44
+ def create_data_stream(name, connection):
45
+ register_script(connection, "create_stream_script", create_stream_script)
46
+ evaluate_script(connection, "create_stream_script", keys=(name,))
47
+
48
+
49
+ class DataStream(BaseSetting):
50
+ """An ordered dictionary of dictionaries in Redis with optionally
51
+ a maximal number of items.
52
+
53
+ The dictionary keys are Redis stream ID (index):
54
+ - "<millisecondsTime>-<sequenceNumber>"
55
+ - "<millisecondsTime>"
56
+ - millisecondsTime
57
+ millisecondsTime: time since the epoch
58
+ sequenceNumber: positive integer (in case the same time)
59
+ examples: '1575038143700-0', '1575038143700-3', 1575038143700, 0, '0'
60
+
61
+ The dictionary values are dictionaries which represent encoded StreamEvent's.
62
+ """
63
+
64
+ def __init__(
65
+ self, name, connection=None, maxlen=None, approximate=True, create=False
66
+ ):
67
+ """
68
+ :param str name:
69
+ :param connection:
70
+ :param int maxlen: maximum len of the stream (None: unlimited)
71
+ :param bool approximate: maxlen is exact or approximate
72
+ """
73
+ super().__init__(name, connection, None, None)
74
+ self._maxlen = maxlen
75
+ self._approximate = approximate
76
+ if create:
77
+ create_data_stream(self.name, self.connection)
78
+
79
+ def __str__(self):
80
+ return f"{self.__class__.__name__}({self.name}, maxlen={self._maxlen})"
81
+
82
+ def __len__(self):
83
+ return self.connection.xlen(self.name)
84
+
85
+ def add(self, adict, id="*", maxlen=None, approximate=None, cnx=None):
86
+ """Add one event to the stream
87
+
88
+ :param dict adict:
89
+ :param str id: Redis stream id ("*" mean auto-generate)
90
+ :param int or None maxlen: maximum stream length
91
+ (None: used the maxlen passed in the constructor)
92
+ """
93
+ if cnx is None:
94
+ cnx = self.connection
95
+ if maxlen is None:
96
+ maxlen = self._maxlen
97
+ if approximate is None:
98
+ approximate = self._approximate
99
+ return cnx.xadd(self.name, adict, id=id, maxlen=maxlen, approximate=approximate)
100
+
101
+ def add_event(self, ev, **kw):
102
+ """
103
+ :param StreamEvent ev:
104
+ :param `**kw`: see `add`
105
+ """
106
+ self.add(ev.encode(), **kw)
107
+
108
+ def remove(self, *indexes):
109
+ """Remove some events using their index."""
110
+ for index in indexes:
111
+ self.connection.xdel(self.name, index)
112
+
113
+ def range(self, from_index="-", to_index="+", count=None, cnx=None):
114
+ """Read stream values.
115
+
116
+ :param str from_index: minimum index (default `-` first one)
117
+ :param str to_index: maximumn index (default '+' last one)
118
+ :param str count: maximum number of return values.
119
+
120
+ :returns list(tuple): (index, dict)
121
+ """
122
+ if cnx is None:
123
+ connection = self.connection
124
+ else:
125
+ connection = cnx
126
+ return connection.xrange(self.name, min=from_index, max=to_index, count=count)
127
+
128
+ def rev_range(self, from_index="+", to_index="-", count=None, cnx=None):
129
+ """Read stream values in reversed order.
130
+
131
+ :param str from_index: maximumn index (default '+' last one)
132
+ :param str to_index: minimum index (default `-` first one)
133
+ :param str count: maximum number of return values.
134
+
135
+ :returns list(tuple): (index, dict)
136
+ """
137
+ if cnx is None:
138
+ connection = self.connection
139
+ else:
140
+ connection = cnx
141
+ return connection.xrevrange(
142
+ self.name, max=from_index, min=to_index, count=count
143
+ )
144
+
145
+ def has_new_data(self, last_index):
146
+ """Has new data after a certain index?
147
+
148
+ :param str, bytes or int last_index:
149
+ :returns bool:
150
+ """
151
+ from_index = self.stream_incr_index(last_index)
152
+ return bool(self.range(from_index=from_index, count=1))
153
+ # return len(self.range(from_index=last_index, count=2)) == 2
154
+
155
+ def get_stream_index(self, idx):
156
+ """Get item using stream ID
157
+
158
+ :param str, bytes or int index: b'1575038143700-3'
159
+ :returns 2-tuple: stream ID, raw dict
160
+ """
161
+ events = self.range(from_index=idx, to_index=idx, count=1)
162
+ if events:
163
+ return events[0]
164
+ else:
165
+ return None, {}
166
+
167
+ def get_sequence_index(self, idx):
168
+ """Get item using the sequence index
169
+
170
+ :param int idx:
171
+ :returns 2-tuple: stream ID, raw dict
172
+ """
173
+ if idx >= 0:
174
+ count = idx + 1
175
+ events = self.range(count=count)
176
+ else:
177
+ count = -idx
178
+ events = self.rev_range(count=count)
179
+ if len(events) == count:
180
+ return events[-1]
181
+ else:
182
+ return None, {}
183
+
184
+ @staticmethod
185
+ def stream_incr_index(index):
186
+ """Next stream index (which may not exist)
187
+
188
+ :param str, bytes or int index: b'1575038143700-3'
189
+ :returns bytes: b'1575038143700-4'
190
+ """
191
+ if isinstance(index, str):
192
+ index = index.encode()
193
+ elif not isinstance(index, bytes):
194
+ index = b"%d" % index
195
+ indexs = index.split(b"-")
196
+ indexs[-1] = b"%d" % (int(indexs[-1]) + 1)
197
+ return b"-".join(indexs)
198
+
199
+ def stream_decr_index(self, index):
200
+ """Previous stream index (which may not exist)
201
+
202
+ :param str, bytes or int index: b'1575038143700-3'
203
+ :returns bytes: b'1575038143700-2'
204
+ """
205
+ if isinstance(index, str):
206
+ index = index.encode()
207
+ elif not isinstance(index, bytes):
208
+ index = b"%d" % index
209
+ indexs = index.split(b"-")
210
+ seq_num = int(indexs[-1]) - 1
211
+ if seq_num < 0:
212
+ from_index = int(indexs[0]) - 1 # 1 millisecond back
213
+ lst = self.rev_range(from_index=index, to_index=from_index, count=2)
214
+ if len(lst) == 2:
215
+ return lst[-1][0]
216
+ else:
217
+ return b"%d" % from_index
218
+ else:
219
+ indexs[-1] = b"%d" % seq_num
220
+ return b"-".join(indexs)
221
+
222
+ def before_last_index(self):
223
+ """Stream ID just before the last index (may not exist)
224
+
225
+ :returns bytes: None when not events in stream
226
+ """
227
+ events = self.rev_range(count=1)
228
+ if events:
229
+ return self.stream_decr_index(events[0][0])
230
+ else:
231
+ return None
232
+
233
+ @staticmethod
234
+ def now_index():
235
+ """Data stream index corresponding to now.
236
+
237
+ :returns int: time since the epoch in milliseconds
238
+ """
239
+ return int(time.time() * 1000)
240
+
241
+
242
+ class DataStreamReaderStopHandler:
243
+ """Allows a DataStreamReader consumer to be stopped gracefully"""
244
+
245
+ def __init__(self):
246
+ self._reader = None
247
+
248
+ def attach(self, reader):
249
+ if reader is self._reader:
250
+ return
251
+ elif self._reader is not None:
252
+ raise RuntimeError("Already attached to a reader")
253
+ else:
254
+ self._reader = reader
255
+
256
+ def detach(self):
257
+ self._reader = None
258
+
259
+ def stop(self):
260
+ if self._reader is not None:
261
+ self._reader.stop_consumer()
262
+
263
+
264
+ class DataStreamReader:
265
+ """This class receives data from several streams and
266
+ creates one queue of events over which you can iterate.
267
+
268
+ Safest to use as a context manager:
269
+
270
+ with DataStreamReader(...) as reader:
271
+ ...
272
+
273
+ Can also be used without context, but don't forget
274
+ to close the reader when you are done:
275
+
276
+ reader = DataStreamReader(...)
277
+ ...
278
+ reader.close()
279
+
280
+ Consume events as follows (only one consumer allowed):
281
+
282
+ for stream, events in reader:
283
+ for index, raw in events:
284
+ ...
285
+
286
+ Streams can be added and removed by the consumer.
287
+ At least one stream must be added before starting
288
+ the consumer.
289
+ """
290
+
291
+ @enum.unique
292
+ class ConsumerState(enum.IntEnum):
293
+ """Writer states:
294
+ * WAITING: consumer is waiting for a new event
295
+ * PROCESSING: consumer is processing an event
296
+ * FINISHED: consumer stopped or stop was requested
297
+ """
298
+
299
+ WAITING = enum.auto()
300
+ PROCESSING = enum.auto()
301
+ FINISHED = enum.auto()
302
+
303
+ # Raw events for the synchronization stream
304
+ SYNC_END = streaming_events.EndEvent().encode()
305
+ SYNC_EVENT = streaming_events.StreamEvent().encode()
306
+
307
+ def __init__(
308
+ self,
309
+ wait=True,
310
+ timeout=None,
311
+ stop_handler=None,
312
+ active_streams=None,
313
+ excluded_stream_names=None,
314
+ ):
315
+ """
316
+ :param bool wait: stop reading when no new events (timeout ignored)
317
+ or keep waiting (with timeout)
318
+ :param num timeout: in seconds (None: never timeout)
319
+ :param DataStreamReaderStopHandler stop_handler: for gracefully stopping
320
+ :param dict active_streams: active streams from another reader
321
+ :param excluded_stream_names: do not subscribe to these streams
322
+ """
323
+ self._has_consumer = False
324
+ self._cnx = None
325
+ self._logger = CustomLogger(logger, self)
326
+
327
+ # Mapping: stream name (str) -> stream info (dict)
328
+ # Streams add/removed by the consumer
329
+ self._streams = {}
330
+ # Streams being actively read
331
+ if active_streams:
332
+ self._active_streams = active_streams
333
+ else:
334
+ self._active_streams = {}
335
+ # Streams to ignore
336
+ if excluded_stream_names:
337
+ self.excluded_stream_names = set(excluded_stream_names)
338
+ else:
339
+ self.excluded_stream_names = set()
340
+
341
+ # Synchronization mechanism (state, event and stream)
342
+ # to ensure that all streams added by the consumer
343
+ # are being checked for new events at least once
344
+ # and stream priorities are being respected
345
+ self.__synchro_stream = None
346
+ self._consumer_state_changed = gevent.event.Event()
347
+ self._consumer_state = self.ConsumerState.PROCESSING
348
+ # State as if we are consuming events
349
+
350
+ # For the reader greenlet
351
+ self._read_task = None
352
+ self._queue = gevent.queue.Queue()
353
+ if wait:
354
+ # xread wait's for new events
355
+ if timeout is None:
356
+ # one xread call: always yield something (no timeout)
357
+ self._block = 0
358
+ elif timeout:
359
+ # one xread call: yield nothing when no event within x milliseconds
360
+ self._block = int(timeout * 1000 + 0.5)
361
+ else:
362
+ raise ValueError("Zero timeout is not supported")
363
+ self._wait = True
364
+ else:
365
+ # one xread call: yield nothing when no events
366
+ self._block = None
367
+ self._wait = False
368
+ # one xread call: yield at most x events (None: no limit)
369
+ self._count = None
370
+
371
+ # Object that allows stopping the consumer gracefully
372
+ if not isinstance(stop_handler, DataStreamReaderStopHandler):
373
+ stop_handler = DataStreamReaderStopHandler()
374
+ stop_handler.attach(self)
375
+ self.stop_handler = stop_handler
376
+
377
+ def __str__(self):
378
+ return "{}({} subscribed, {} active, {} consumer)".format(
379
+ self.__class__.__name__,
380
+ self.n_subscribed_streams,
381
+ self.n_active_streams,
382
+ self._consumer_state.name,
383
+ )
384
+
385
+ @property
386
+ def n_subscribed_streams(self):
387
+ return len(self._streams)
388
+
389
+ @property
390
+ def n_active_streams(self):
391
+ return len(self._active_streams)
392
+
393
+ @property
394
+ def _consumer_state(self):
395
+ return self.__consumer_state
396
+
397
+ @_consumer_state.setter
398
+ def _consumer_state(self, state):
399
+ self.__consumer_state = state
400
+ self._consumer_state_changed.set()
401
+
402
+ def __enter__(self):
403
+ return self
404
+
405
+ def __exit__(self, *args):
406
+ self.close()
407
+
408
+ def close(self):
409
+ """Stop reader and consumer"""
410
+ self.stop_consumer()
411
+ self.stop_read_task()
412
+
413
+ def stop_consumer(self):
414
+ """Stop consumer gracefully"""
415
+ self._queue.put(StopIteration)
416
+
417
+ def _start_read_task(self):
418
+ """Start the stream reading loop (spawn when not already spawned)"""
419
+ if not self._read_task:
420
+ self._read_task = gevent.spawn(self._read_task_main)
421
+
422
+ def stop_read_task(self):
423
+ """Stop the stream reading loop (greenlet)"""
424
+ if not self._read_task:
425
+ return
426
+ self._publish_synchro_event(end=True)
427
+ self._consumer_state = self.ConsumerState.FINISHED
428
+ # State as if we the consumer already finished
429
+ try:
430
+ self._read_task.get()
431
+ self._read_task = None
432
+ finally:
433
+ self._synchro_stream.clear()
434
+
435
+ @property
436
+ def _synchro_stream(self):
437
+ """The read synchronization stream (created when missing).
438
+ This data stream is for internal use only (i.e. not meant for
439
+ the consumer).
440
+ """
441
+ if self.__synchro_stream is not None:
442
+ return self.__synchro_stream
443
+ if not self._streams:
444
+ return None
445
+
446
+ # Get the connection shared by all streams
447
+ cnxs = set(adict["stream"]._cnx() for adict in self._streams.values())
448
+ if len(cnxs) != 1:
449
+ raise TypeError("All streams must have the same redis connection")
450
+ cnx = cnxs.pop()
451
+ if self.connection is None:
452
+ self._cnx = cnx
453
+ elif cnx != self.connection:
454
+ raise TypeError("All streams must have the same redis connection")
455
+
456
+ # Create the synchronization stream
457
+ self.__synchro_stream = DataStream(
458
+ str(uuid.uuid4()), maxlen=16, connection=cnx, create=True
459
+ )
460
+ return self.__synchro_stream
461
+
462
+ @property
463
+ def connection(self):
464
+ return self._cnx
465
+
466
+ def _publish_synchro_event(self, end=False):
467
+ """Add an event the read synchronization stream"""
468
+ synchro_stream = self._synchro_stream
469
+ if synchro_stream is None:
470
+ return
471
+ with pipeline(synchro_stream):
472
+ if end:
473
+ self._logger.debug("PUBLISH SYNC_END")
474
+ synchro_stream.add(self.SYNC_END)
475
+ else:
476
+ self._logger.debug("PUBLISH SYNC_EVENT")
477
+ synchro_stream.add(self.SYNC_EVENT)
478
+ synchro_stream.ttl(60)
479
+
480
+ @contextmanager
481
+ def _update_streams_context(self):
482
+ """Publish synchronization event (and start reader loop)
483
+ if the number of streams are modified within the context.
484
+ """
485
+ nbefore = len(self._streams)
486
+ try:
487
+ yield
488
+ finally:
489
+ nafter = len(self._streams)
490
+ if nafter != nbefore:
491
+ self._publish_synchro_event()
492
+ self._start_read_task()
493
+
494
+ def check_stream_connection(self, stream):
495
+ """Make sure all streams share the same connection
496
+ :param DataStream stream:
497
+ :raises TypeError: not the same connection
498
+ """
499
+ if self.connection is None:
500
+ self._cnx = stream.connection
501
+ elif self.connection is not stream.connection:
502
+ raise TypeError("All streams must have the same redis connection")
503
+
504
+ def add_streams(
505
+ self, *streams, first_index=None, priority=0, ignore_excluded=False, **info
506
+ ):
507
+ """Add data streams to the reader.
508
+
509
+ :param `*streams`: DataStream objects
510
+ :param str or int first_index: Redis stream ID to start reading from
511
+ (None: only new events)
512
+ :param int priority: data from streams with a lower priority is never
513
+ yielded as long as higher priority streams have
514
+ data. Lower number means higher priority.
515
+ :param bool ignore_excluded: ignore `excluded_stream_names`
516
+ :param dict info: additional stream info
517
+ """
518
+ if priority < 0:
519
+ raise ValueError("Priority must be a positive number")
520
+ with self._update_streams_context():
521
+ for stream in streams:
522
+ if stream.name in self._streams:
523
+ continue
524
+ if not ignore_excluded and stream.name in self.excluded_stream_names:
525
+ continue
526
+ self._logger.debug("ADD STREAM %s", stream.name)
527
+ self.check_stream_connection(stream)
528
+ sinfo = self._compile_stream_info(
529
+ stream, first_index=first_index, priority=priority, **info
530
+ )
531
+ self._streams[stream.name] = sinfo
532
+
533
+ def add_named_streams(self, *names, stream_kwargs=None, add_kwargs=None):
534
+ """Add data streams to the reader (create when non-existing).
535
+
536
+ :param `*names`: DataStream names
537
+ :param dict stream_kwargs: for DataStream instantiation
538
+ :param dict add_kwargs: passed to `add_streams`
539
+ """
540
+ if stream_kwargs is None:
541
+ stream_kwargs = {}
542
+ if add_kwargs is None:
543
+ add_kwargs = {}
544
+ streams = (
545
+ DataStream(name, connection=self.connection, **stream_kwargs)
546
+ for name in names
547
+ )
548
+ self.add_streams(*streams, **add_kwargs)
549
+
550
+ def remove_streams(self, *streams):
551
+ """Remove data streams from the reader.
552
+
553
+ :param `*streams`: DataStream objects
554
+ """
555
+ with self._update_streams_context():
556
+ for stream in streams:
557
+ self._streams.pop(stream.name, None)
558
+
559
+ def remove_matching_streams(self, stream_name_pattern):
560
+ """Remove data streams from the reader.
561
+
562
+ :param str stream_name_pattern:
563
+ """
564
+ with self._update_streams_context():
565
+ for name in list(self._streams.keys()):
566
+ if fnmatch.fnmatch(name, stream_name_pattern):
567
+ self._streams.pop(name, None)
568
+
569
+ def get_stream_info(self, stream, key, default=None):
570
+ """
571
+ :param DataStream or str stream:
572
+ :return Any: None when missing
573
+ """
574
+ if isinstance(stream, DataStream):
575
+ stream = stream.name
576
+ info = self._streams.get(stream, {})
577
+ return info.get(key, default)
578
+
579
+ @staticmethod
580
+ def _compile_stream_info(stream, first_index=None, priority=0, **extra):
581
+ """
582
+ :param DataStream stream:
583
+ :param str or int first_index: Redis stream ID to start reading from
584
+ (None: only new events)
585
+ :param int priority: order of streams when reading them in batch
586
+ :param `**extra`: extra stream info to be stored
587
+ """
588
+ if first_index is None:
589
+ first_index = "$"
590
+ fixed = {"stream": stream, "first_index": first_index, "priority": priority}
591
+ extra.update(fixed)
592
+ return extra
593
+
594
+ def reset_first_index(self, first_index=None):
595
+ """
596
+ :param str or int first_index: Redis stream ID to start reading from
597
+ (None: only new events)
598
+ """
599
+ if self._has_consumer:
600
+ raise RuntimeError("Cannot reset the index while consuming events")
601
+ self._consumer_state = self.ConsumerState.PROCESSING
602
+ # State as if we are consuming events
603
+ if first_index is None:
604
+ first_index = "$"
605
+ for info in self._streams.values():
606
+ info["first_index"] = first_index
607
+ self._publish_synchro_event()
608
+ self._start_read_task()
609
+
610
+ def _read_active_streams(self):
611
+ """Get data from the active streams
612
+
613
+ :returns list(2-tuple): list((name, events))
614
+ name: name of the stream
615
+ events: list((index, raw)))
616
+ """
617
+ if not self._active_streams:
618
+ return []
619
+ # Map stream name to index from which to read:
620
+ streams_to_read = sorted(
621
+ self._active_streams.items(), key=lambda item: item[1]["priority"]
622
+ )
623
+ streams_to_read = {k: v["first_index"] for k, v in streams_to_read}
624
+
625
+ # first_index: yield events with stream ID larger then this
626
+ # block=None: yield nothing when no events
627
+ # block=0: always yield something (no timeout)
628
+ # blocks>0: yield nothing when no event within x milliseconds
629
+ # count: yield at most x events in one read operation
630
+ return self.connection.xread(
631
+ streams_to_read, count=self._count, block=self._block
632
+ )
633
+
634
+ @contextmanager
635
+ def _read_task_context(self):
636
+ """Start/stop the synchronization stream and exception
637
+ handling for the the reader task.
638
+ """
639
+ try:
640
+ if self._synchro_stream is None:
641
+ raise RuntimeError(
642
+ "Subscribe to at least one stream before iterating over "
643
+ + self.__class__.__name__
644
+ )
645
+ sinfo = self._compile_stream_info(
646
+ self._synchro_stream, first_index=0, priority=-1
647
+ )
648
+ self._active_streams[self._synchro_stream.name] = sinfo
649
+ yield
650
+ except (StopIteration, gevent.GreenletExit, KeyboardInterrupt):
651
+ pass
652
+ except Exception as e:
653
+ self._queue.put(e) # Stop consumer with exception
654
+ raise
655
+ finally:
656
+ self.stop_consumer() # Stop consumer gracefully
657
+ self._active_streams.pop(self._synchro_stream.name, None)
658
+
659
+ @property
660
+ def _synchro_index(self):
661
+ """The last index read from the synchronization stream"""
662
+ return self._active_streams[self._synchro_stream.name]["first_index"]
663
+
664
+ @_synchro_index.setter
665
+ def _synchro_index(self, value):
666
+ self._active_streams[self._synchro_stream.name]["first_index"] = value
667
+
668
+ def has_new_synchro_events(self):
669
+ """The synchronization stream has unread events"""
670
+ return self._synchro_stream.has_new_data(self._synchro_index)
671
+
672
+ def _read_task_main(self):
673
+ """Main reading loop. The loop ends when there are no more
674
+ synchronization stream events or on SYNC_END.
675
+ """
676
+ with self._read_task_context():
677
+ keep_reading = True
678
+ synchro_name = self._synchro_stream.name
679
+ self._logger.debug("READING events starts.")
680
+ while keep_reading:
681
+ # When not waiting for new events (wait=False)
682
+ # will stop reading after reading all current
683
+ # events, unless the synchro stream has events
684
+ # (see further)
685
+ keep_reading = self._wait
686
+
687
+ # When wait=True: wait indefinitely when no events
688
+ self._logger.debug("READING events ...")
689
+ lst = self._read_active_streams()
690
+ self._logger.debug("RECEIVED events %d streams", len(lst))
691
+ read_priority = None
692
+ for name, events in lst:
693
+ if not events:
694
+ # This happens because of empty stream creation
695
+ # in create_stream_script.
696
+ continue
697
+ name = name.decode()
698
+ sinfo = self._active_streams[name]
699
+ if read_priority is None:
700
+ read_priority = sinfo["priority"]
701
+ if sinfo["priority"] > read_priority:
702
+ # Lower priority streams are never read until
703
+ # while higher priority streams have unread data
704
+ keep_reading = True
705
+ self._logger.debug("SKIP %d events from %s", len(events), name)
706
+ break
707
+ self._logger.debug("PROCESS %d events from %s", len(events), name)
708
+ if name == synchro_name:
709
+ self._process_synchro_events(events)
710
+ keep_reading = True
711
+ else:
712
+ self._process_consumer_events(sinfo, events)
713
+ gevent.idle()
714
+ self._logger.debug("EVENTS processed.")
715
+
716
+ # Keep reading when active streams are modified
717
+ # by the consumer. This ensures that all streams
718
+ # are read at least once.
719
+ self._wait_no_consuming()
720
+ if not keep_reading:
721
+ keep_reading = self.has_new_synchro_events()
722
+ self._logger.debug("READING events finished.")
723
+
724
+ def _wait_no_consuming(self):
725
+ """Wait until the consumer is not processing an event
726
+ (which can result in adding/removing streams).
727
+
728
+ If you yield to the gevent loop, you'll need to call
729
+ this again if you want to ensure streams are fixed.
730
+ """
731
+ while self._consumer_state == self.ConsumerState.PROCESSING:
732
+ self._consumer_state_changed.clear()
733
+ self._consumer_state_changed.wait()
734
+
735
+ def _process_synchro_events(self, events):
736
+ """Process events from the synchronization stream.
737
+ Possible events are add stream, remove stream or end.
738
+
739
+ :param list events: list((index, raw)))
740
+ """
741
+ index = self._synchro_index
742
+ for index, raw in events:
743
+ if streaming_events.EndEvent.istype(raw):
744
+ # stop reader loop (does not stop consumer)
745
+ self._logger.debug("RECEIVED stop event")
746
+ raise StopIteration
747
+ self._synchro_index = index
748
+ self._update_active_streams()
749
+
750
+ def _log_events(self, task, stream, events):
751
+ if self._logger.getEffectiveLevel() > logging.DEBUG:
752
+ return
753
+ content = "\n ".join(self._log_events_content(events))
754
+ self._logger.debug("%s from %s:\n %s", task, stream.name, content)
755
+
756
+ @staticmethod
757
+ def _log_events_content(events):
758
+ for _, raw in events:
759
+ evttype = raw[b"__EVENT__"].decode()
760
+ db_name = raw.get(b"db_name", b"").decode()
761
+ nbytes = sys.getsizeof(raw)
762
+ if db_name:
763
+ yield f"{evttype}: {db_name} ({nbytes} bytes)"
764
+ else:
765
+ yield f"{evttype}: {nbytes} bytes"
766
+
767
+ def _process_consumer_events(self, sinfo, events):
768
+ """Queue stream events and progress the index
769
+ for the next read operation.
770
+
771
+ :param dict sinfo: stream info
772
+ :param list events: list((index, raw)))
773
+ """
774
+ self._log_events("BUFFER events", sinfo["stream"], events)
775
+ self._queue.put((sinfo["stream"], events))
776
+ sinfo["first_index"] = events[-1][0]
777
+
778
+ def _update_active_streams(self):
779
+ """Synchronize the consumer defined streams with
780
+ the streams that are being actively read.
781
+ """
782
+ for stream_name, stream_info in self._streams.items():
783
+ self._active_streams.setdefault(stream_name, stream_info)
784
+ inactive_streams = (
785
+ self._active_streams.keys()
786
+ - self._streams.keys()
787
+ - {self._synchro_stream.name}
788
+ )
789
+ for name in inactive_streams:
790
+ self._active_streams.pop(name)
791
+
792
+ def __iter__(self):
793
+ """Iterate over the serialized stream events (this is the consumer).
794
+
795
+ yields (stream, list((index, raw))): index is the stream ID (bytes) and
796
+ raw is the event data (dict)
797
+ """
798
+ try:
799
+ if self._has_consumer:
800
+ raise RuntimeError("Only one consumer allowed")
801
+ self._has_consumer = True
802
+ self._consumer_state = self.ConsumerState.WAITING
803
+ # if no stream and don't want to wait
804
+ # add a StopIteration in the Queue
805
+ if not self._streams and not self._wait:
806
+ self._queue.put(StopIteration)
807
+
808
+ self._logger.debug("CONSUMING events starts.")
809
+ for item in self._queue:
810
+ if isinstance(item, Exception):
811
+ raise item
812
+ self._log_events("CONSUME events", item[0], item[1])
813
+ self._consumer_state = self.ConsumerState.PROCESSING
814
+ yield item
815
+ self._consumer_state = self.ConsumerState.WAITING
816
+ finally:
817
+ self._consumer_state = self.ConsumerState.FINISHED
818
+ self._has_consumer = False
819
+ self._logger.debug("CONSUMING events finished.")