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.
- blissdata/__init__.py +17 -0
- blissdata/beacon/__init__.py +1 -0
- blissdata/beacon/_base.py +106 -0
- blissdata/beacon/config.py +24 -0
- blissdata/beacon/data.py +45 -0
- blissdata/beacon/files.py +141 -0
- blissdata/client.py +62 -0
- blissdata/common/__init__.py +15 -0
- blissdata/common/utils.py +159 -0
- blissdata/data/__init__.py +15 -0
- blissdata/data/events/__init__.py +22 -0
- blissdata/data/events/channel.py +149 -0
- blissdata/data/events/lima.py +478 -0
- blissdata/data/events/node.py +28 -0
- blissdata/data/events/scan.py +49 -0
- blissdata/data/events/walk.py +42 -0
- blissdata/data/expiration.py +42 -0
- blissdata/data/lima_image.py +465 -0
- blissdata/data/node.py +1619 -0
- blissdata/data/nodes/__init__.py +11 -0
- blissdata/data/nodes/channel.py +394 -0
- blissdata/data/nodes/dataset.py +82 -0
- blissdata/data/nodes/dataset_collection.py +12 -0
- blissdata/data/nodes/lima.py +422 -0
- blissdata/data/nodes/node_ref_channel.py +34 -0
- blissdata/data/nodes/proposal.py +12 -0
- blissdata/data/nodes/scan.py +192 -0
- blissdata/data/nodes/scan_group.py +12 -0
- blissdata/data/remote_node.py +204 -0
- blissdata/data/scan.py +666 -0
- blissdata/h5api/__init__.py +1 -0
- blissdata/h5api/abstract.py +97 -0
- blissdata/h5api/dynamic_hdf5.py +153 -0
- blissdata/h5api/file_arguments.py +28 -0
- blissdata/h5api/static_hdf5.py +139 -0
- blissdata/h5api/utils/__init__.py +0 -0
- blissdata/h5api/utils/bliss.py +138 -0
- blissdata/h5api/utils/hdf5.py +280 -0
- blissdata/h5api/utils/hdf5_retry.py +98 -0
- blissdata/h5api/utils/lima.py +286 -0
- blissdata/h5api/utils/types.py +13 -0
- blissdata/redis/__init__.py +12 -0
- blissdata/redis/caching.py +390 -0
- blissdata/redis/connection.py +169 -0
- blissdata/redis/manager.py +164 -0
- blissdata/redis/proxy.py +971 -0
- blissdata/redis/scripting.py +24 -0
- blissdata/settings.py +1174 -0
- blissdata/streaming.py +819 -0
- blissdata/streaming_events.py +355 -0
- blissdata/tests/__init__.py +0 -0
- blissdata/tests/beacon/__init__.py +0 -0
- blissdata/tests/beacon/test_config.py +26 -0
- blissdata/tests/beacon/test_data.py +50 -0
- blissdata/tests/beacon/test_files.py +80 -0
- blissdata/tests/conftest.py +0 -0
- blissdata/tests/h5api/__init__.py +0 -0
- blissdata/tests/h5api/scanner.py +390 -0
- blissdata/tests/h5api/test_dynamic_files.py +309 -0
- blissdata/tests/h5api/test_static_files.py +169 -0
- blissdata/tests/redis/__init__.py +0 -0
- blissdata/tests/redis/manager.py +23 -0
- blissdata-0.3.4.dist-info/LICENSE +165 -0
- blissdata-0.3.4.dist-info/METADATA +79 -0
- blissdata-0.3.4.dist-info/RECORD +67 -0
- blissdata-0.3.4.dist-info/WHEEL +5 -0
- 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.")
|