openrvdas.logger.writers.cached_data_writer

No module-level documentation available.
  1#!/usr/bin/env python3
  2
  3import asyncio
  4import json
  5import logging
  6import ssl
  7import threading
  8
  9from typing import Union
 10try:
 11    import websockets
 12    WEBSOCKETS_INSTALLED = True
 13except ImportError:
 14    WEBSOCKETS_INSTALLED = False
 15
 16from logger.writers.writer import Writer  # noqa: E402
 17from logger.utils.das_record import DASRecord  # noqa: E402
 18
 19
 20class CachedDataWriter(Writer):
 21    def __init__(self, data_server, start_server=False, back_seconds=480,
 22                 cleanup_interval=6, update_interval=1,
 23                 max_backup=60 * 60 * 24, use_wss=False, check_cert=False, **kwargs):
 24        """Feed passed records to a CachedDataServer via a websocket. Expects
 25        records in DASRecord or dict formats.
 26        ```
 27        data_server    [host:]port on which to look for data server
 28
 29        back_seconds   Number of seconds of back data to hold in cache
 30
 31        cleanup_interval   Remove old data every N seconds
 32
 33        update_interval    Serve updates to websocket clients every N seconds
 34
 35        max_backup    If the writer isn't able to connect to the data server,
 36                      it will locally cache records until it can. To avoid
 37                      unbounded memory usage, if max_backup is nonzero, it will
 38                      cache at most max_backup records before dropping the
 39                      oldest records. By default, cache one day's worth of
 40                      records at 1 Hz (86,400 records). If max_backup is zero,
 41                      cache size is unbounded.
 42
 43        use_wss -     If True, use secure websockets
 44
 45        check_cert  - If True and use_wss is True, check the server's TLS certificate
 46                      for validity; if a str, use as local filepath location of .pem
 47                      file to check against.
 48
 49        ```
 50        """
 51        if not WEBSOCKETS_INSTALLED:
 52            raise ImportError('CachedDataWriter requires Python "websockets" module; '
 53                              'please run "pip install websockets"')
 54
 55        super().__init__(**kwargs)  # processes 'quiet' and type hints
 56
 57        host_port = data_server.split(':')
 58        if len(host_port) == 1:
 59            self.data_server = 'localhost:' + data_server  # they gave us '8766'
 60        elif not len(host_port[0]):
 61            self.data_server = 'localhost' + data_server   # they gave us ':8766'
 62        else:
 63            self.data_server = data_server                 # they gave us 'host:8766'
 64
 65        self.websocket = None
 66        self.back_seconds = back_seconds
 67        self.cleanup_interval = cleanup_interval
 68        self.use_wss = use_wss
 69        self.check_cert = check_cert
 70        self.event_loop = asyncio.new_event_loop()
 71
 72        # "loop" parameter removed in Ubuntu 22, but needed in earlier releases
 73        try:
 74            self.send_queue = asyncio.Queue(maxsize=max_backup)
 75        except RuntimeError:
 76            self.send_queue = asyncio.Queue(maxsize=max_backup, loop=self.event_loop)
 77
 78        # Start the thread that will asynchronously pull stuff from the
 79        # queue and send to the websocket. Also will, if we've got our oue
 80        # data server, run cleanup from time to time.
 81        self.cached_data_writer_thread = threading.Thread(
 82            name='cached_data_writer_thread',
 83            target=self._cached_data_writer_loop, daemon=True)
 84        self.cached_data_writer_thread.start()
 85
 86    ############################
 87    def _cached_data_writer_loop(self):
 88        """Use an inner async function to pull stuff from the queue and send
 89        to the websocket. Also, if we've got our oue data server, run
 90        cleanup from time to time.
 91        """
 92
 93        ############################
 94        async def _async_send_records_loop(self):
 95            """Inner async function that actually does websocket writes
 96            and cleanups.
 97            """
 98            while True:
 99                logging.debug('CachedDataWriter trying to connect to '
100                              + self.data_server)
101                try:
102                    if self.use_wss:
103                        # If check_cert is a str, take it as the location of the
104                        # .pem file we'll check for validity. Otherwise, if not
105                        # False, take as a bool to verify by own means.
106                        ws_data_server = 'wss://' + self.data_server
107                        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
108                        if self.check_cert:
109                            if isinstance(self.check_cert, str):
110                                ssl_context.load_verify_locations(self.check_cert)
111                            else:
112                                ssl_context.verify_mode = ssl.CERT_REQUIRED
113                        else:
114                            ssl_context.verify_mode = ssl.CERT_NONE
115
116                    else:  # not using wss
117                        ws_data_server = 'ws://' + self.data_server
118                        ssl_context = None
119
120                    logging.debug(f'CachedDataWriter connecting to {ws_data_server}')
121                    async with websockets.connect(ws_data_server, ssl=ssl_context) as ws:
122                        logging.debug(f'Connected to data server {ws_data_server}')
123                        while True:
124                            try:
125                                record = self.send_queue.get_nowait()
126                                logging.debug('sending record: %s', record)
127                                record = {'type': 'publish', 'data': record}
128                                await ws.send(json.dumps(record))
129                                response = await ws.recv()
130                                logging.debug('received response: %s', response)
131                            except asyncio.QueueEmpty:
132                                await asyncio.sleep(.2)
133
134                except BrokenPipeError:
135                    pass
136                except AttributeError as e:
137                    logging.warning('CachedDataWriter websocket loop error: %s', e)
138                    await asyncio.sleep(0.1)
139                except websockets.exceptions.ConnectionClosed:
140                    logging.warning('CachedDataWriter lost websocket connection to '
141                                    'data server; trying to reconnect.')
142                    await asyncio.sleep(0.2)
143
144                except websockets.exceptions.InvalidStatusCode:
145                    logging.warning('CachedDataWriter InvalidStatusCode connecting to '
146                                    'data server; trying to reconnect.')
147                    await asyncio.sleep(0.2)
148
149                # If the websocket connection failed
150                except OSError as e:
151                    logging.warning('CachedDataWriter websocket connection to %s '
152                                    'failed; sleeping before trying again: %s',
153                                    self.data_server, str(e))
154                    await asyncio.sleep(5)
155
156        # Now call the async process in its own event loop
157        self.event_loop.run_until_complete(_async_send_records_loop(self))
158        self.event_loop.close()
159
160    ############################
161    def write(self, record: Union[DASRecord, dict]):
162        """Write out record. Expects passed records to be in one of three
163        formats:
164
165        1) DASRecord
166
167        2) a list of DASRecords
168
169        3) a dict encoding optionally a source data_id and timestamp and a
170           mandatory 'fields' key of field_name: value pairs. This is the format
171           emitted by default by ParseTransform:
172
173           {
174             'data_id': ...,
175             'timestamp': ...,
176             'fields': {
177               field_name: value,    # use default timestamp of 'now'
178               field_name: value,
179               ...
180             }
181           }
182
183        A twist on format (3) THAT WE'RE PROBABLY GOING TO PHASE OUT IN
184        FAVOR OF (2) is that the values may either be a singleton (int,
185        float, string, etc) or a list. If the value is a singleton, it is
186        taken at face value. If it is a list, it is assumed to be a list
187        of (value, timestamp) tuples, in which case the top-level
188        timestamp, if any, is ignored.
189
190           {
191             'data_id': ...,
192             'timestamp': ...,
193             'fields': {
194                field_name: [(timestamp, value), (timestamp, value),...],
195                field_name: [(timestamp, value), (timestamp, value),...],
196                ...
197             }
198           }
199
200        """
201        # See if it's something we can process, and if not, try digesting
202        if not self.can_process_record(record):  # inherited from BaseModule()
203            self.digest_record(record)  # inherited from BaseModule()
204            return
205
206        # Convert to a dict - inefficient, I know...
207        if isinstance(record, DASRecord):
208            record = json.loads(record.as_json())
209        if isinstance(record, dict):
210            # If our local queue is full, throw away the oldest entries
211            while self.send_queue.full():
212                try:
213                    logging.debug('CachedDataWriter queue full - dropping oldest...')
214                    self.send_queue.get_nowait()
215                except asyncio.QueueEmpty:
216                    logging.warning('CachedDataWriter queue is both full and empty?!?')
217
218            # Enqueue our latest record for send
219            try:
220                self.send_queue.put_nowait(record)
221            except asyncio.queues.QueueFull:
222                logging.warning('CachedDataWriter unable to write: write queue full')
223        else:
224            if not self.quiet:
225                logging.warning('CachedDataWriter got non-dict/DASRecord object of '
226                                'type %s: %s', type(record), str(record))
class CachedDataWriter(logger.writers.writer.Writer):
 21class CachedDataWriter(Writer):
 22    def __init__(self, data_server, start_server=False, back_seconds=480,
 23                 cleanup_interval=6, update_interval=1,
 24                 max_backup=60 * 60 * 24, use_wss=False, check_cert=False, **kwargs):
 25        """Feed passed records to a CachedDataServer via a websocket. Expects
 26        records in DASRecord or dict formats.
 27        ```
 28        data_server    [host:]port on which to look for data server
 29
 30        back_seconds   Number of seconds of back data to hold in cache
 31
 32        cleanup_interval   Remove old data every N seconds
 33
 34        update_interval    Serve updates to websocket clients every N seconds
 35
 36        max_backup    If the writer isn't able to connect to the data server,
 37                      it will locally cache records until it can. To avoid
 38                      unbounded memory usage, if max_backup is nonzero, it will
 39                      cache at most max_backup records before dropping the
 40                      oldest records. By default, cache one day's worth of
 41                      records at 1 Hz (86,400 records). If max_backup is zero,
 42                      cache size is unbounded.
 43
 44        use_wss -     If True, use secure websockets
 45
 46        check_cert  - If True and use_wss is True, check the server's TLS certificate
 47                      for validity; if a str, use as local filepath location of .pem
 48                      file to check against.
 49
 50        ```
 51        """
 52        if not WEBSOCKETS_INSTALLED:
 53            raise ImportError('CachedDataWriter requires Python "websockets" module; '
 54                              'please run "pip install websockets"')
 55
 56        super().__init__(**kwargs)  # processes 'quiet' and type hints
 57
 58        host_port = data_server.split(':')
 59        if len(host_port) == 1:
 60            self.data_server = 'localhost:' + data_server  # they gave us '8766'
 61        elif not len(host_port[0]):
 62            self.data_server = 'localhost' + data_server   # they gave us ':8766'
 63        else:
 64            self.data_server = data_server                 # they gave us 'host:8766'
 65
 66        self.websocket = None
 67        self.back_seconds = back_seconds
 68        self.cleanup_interval = cleanup_interval
 69        self.use_wss = use_wss
 70        self.check_cert = check_cert
 71        self.event_loop = asyncio.new_event_loop()
 72
 73        # "loop" parameter removed in Ubuntu 22, but needed in earlier releases
 74        try:
 75            self.send_queue = asyncio.Queue(maxsize=max_backup)
 76        except RuntimeError:
 77            self.send_queue = asyncio.Queue(maxsize=max_backup, loop=self.event_loop)
 78
 79        # Start the thread that will asynchronously pull stuff from the
 80        # queue and send to the websocket. Also will, if we've got our oue
 81        # data server, run cleanup from time to time.
 82        self.cached_data_writer_thread = threading.Thread(
 83            name='cached_data_writer_thread',
 84            target=self._cached_data_writer_loop, daemon=True)
 85        self.cached_data_writer_thread.start()
 86
 87    ############################
 88    def _cached_data_writer_loop(self):
 89        """Use an inner async function to pull stuff from the queue and send
 90        to the websocket. Also, if we've got our oue data server, run
 91        cleanup from time to time.
 92        """
 93
 94        ############################
 95        async def _async_send_records_loop(self):
 96            """Inner async function that actually does websocket writes
 97            and cleanups.
 98            """
 99            while True:
100                logging.debug('CachedDataWriter trying to connect to '
101                              + self.data_server)
102                try:
103                    if self.use_wss:
104                        # If check_cert is a str, take it as the location of the
105                        # .pem file we'll check for validity. Otherwise, if not
106                        # False, take as a bool to verify by own means.
107                        ws_data_server = 'wss://' + self.data_server
108                        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
109                        if self.check_cert:
110                            if isinstance(self.check_cert, str):
111                                ssl_context.load_verify_locations(self.check_cert)
112                            else:
113                                ssl_context.verify_mode = ssl.CERT_REQUIRED
114                        else:
115                            ssl_context.verify_mode = ssl.CERT_NONE
116
117                    else:  # not using wss
118                        ws_data_server = 'ws://' + self.data_server
119                        ssl_context = None
120
121                    logging.debug(f'CachedDataWriter connecting to {ws_data_server}')
122                    async with websockets.connect(ws_data_server, ssl=ssl_context) as ws:
123                        logging.debug(f'Connected to data server {ws_data_server}')
124                        while True:
125                            try:
126                                record = self.send_queue.get_nowait()
127                                logging.debug('sending record: %s', record)
128                                record = {'type': 'publish', 'data': record}
129                                await ws.send(json.dumps(record))
130                                response = await ws.recv()
131                                logging.debug('received response: %s', response)
132                            except asyncio.QueueEmpty:
133                                await asyncio.sleep(.2)
134
135                except BrokenPipeError:
136                    pass
137                except AttributeError as e:
138                    logging.warning('CachedDataWriter websocket loop error: %s', e)
139                    await asyncio.sleep(0.1)
140                except websockets.exceptions.ConnectionClosed:
141                    logging.warning('CachedDataWriter lost websocket connection to '
142                                    'data server; trying to reconnect.')
143                    await asyncio.sleep(0.2)
144
145                except websockets.exceptions.InvalidStatusCode:
146                    logging.warning('CachedDataWriter InvalidStatusCode connecting to '
147                                    'data server; trying to reconnect.')
148                    await asyncio.sleep(0.2)
149
150                # If the websocket connection failed
151                except OSError as e:
152                    logging.warning('CachedDataWriter websocket connection to %s '
153                                    'failed; sleeping before trying again: %s',
154                                    self.data_server, str(e))
155                    await asyncio.sleep(5)
156
157        # Now call the async process in its own event loop
158        self.event_loop.run_until_complete(_async_send_records_loop(self))
159        self.event_loop.close()
160
161    ############################
162    def write(self, record: Union[DASRecord, dict]):
163        """Write out record. Expects passed records to be in one of three
164        formats:
165
166        1) DASRecord
167
168        2) a list of DASRecords
169
170        3) a dict encoding optionally a source data_id and timestamp and a
171           mandatory 'fields' key of field_name: value pairs. This is the format
172           emitted by default by ParseTransform:
173
174           {
175             'data_id': ...,
176             'timestamp': ...,
177             'fields': {
178               field_name: value,    # use default timestamp of 'now'
179               field_name: value,
180               ...
181             }
182           }
183
184        A twist on format (3) THAT WE'RE PROBABLY GOING TO PHASE OUT IN
185        FAVOR OF (2) is that the values may either be a singleton (int,
186        float, string, etc) or a list. If the value is a singleton, it is
187        taken at face value. If it is a list, it is assumed to be a list
188        of (value, timestamp) tuples, in which case the top-level
189        timestamp, if any, is ignored.
190
191           {
192             'data_id': ...,
193             'timestamp': ...,
194             'fields': {
195                field_name: [(timestamp, value), (timestamp, value),...],
196                field_name: [(timestamp, value), (timestamp, value),...],
197                ...
198             }
199           }
200
201        """
202        # See if it's something we can process, and if not, try digesting
203        if not self.can_process_record(record):  # inherited from BaseModule()
204            self.digest_record(record)  # inherited from BaseModule()
205            return
206
207        # Convert to a dict - inefficient, I know...
208        if isinstance(record, DASRecord):
209            record = json.loads(record.as_json())
210        if isinstance(record, dict):
211            # If our local queue is full, throw away the oldest entries
212            while self.send_queue.full():
213                try:
214                    logging.debug('CachedDataWriter queue full - dropping oldest...')
215                    self.send_queue.get_nowait()
216                except asyncio.QueueEmpty:
217                    logging.warning('CachedDataWriter queue is both full and empty?!?')
218
219            # Enqueue our latest record for send
220            try:
221                self.send_queue.put_nowait(record)
222            except asyncio.queues.QueueFull:
223                logging.warning('CachedDataWriter unable to write: write queue full')
224        else:
225            if not self.quiet:
226                logging.warning('CachedDataWriter got non-dict/DASRecord object of '
227                                'type %s: %s', type(record), str(record))

Base class Writer about which we know nothing else. By default the input format is Unknown unless overridden.

Passes arguments quiet, encoding and encoding_errors up to BaseModule

CachedDataWriter( data_server, start_server=False, back_seconds=480, cleanup_interval=6, update_interval=1, max_backup=86400, use_wss=False, check_cert=False, **kwargs)
22    def __init__(self, data_server, start_server=False, back_seconds=480,
23                 cleanup_interval=6, update_interval=1,
24                 max_backup=60 * 60 * 24, use_wss=False, check_cert=False, **kwargs):
25        """Feed passed records to a CachedDataServer via a websocket. Expects
26        records in DASRecord or dict formats.
27        ```
28        data_server    [host:]port on which to look for data server
29
30        back_seconds   Number of seconds of back data to hold in cache
31
32        cleanup_interval   Remove old data every N seconds
33
34        update_interval    Serve updates to websocket clients every N seconds
35
36        max_backup    If the writer isn't able to connect to the data server,
37                      it will locally cache records until it can. To avoid
38                      unbounded memory usage, if max_backup is nonzero, it will
39                      cache at most max_backup records before dropping the
40                      oldest records. By default, cache one day's worth of
41                      records at 1 Hz (86,400 records). If max_backup is zero,
42                      cache size is unbounded.
43
44        use_wss -     If True, use secure websockets
45
46        check_cert  - If True and use_wss is True, check the server's TLS certificate
47                      for validity; if a str, use as local filepath location of .pem
48                      file to check against.
49
50        ```
51        """
52        if not WEBSOCKETS_INSTALLED:
53            raise ImportError('CachedDataWriter requires Python "websockets" module; '
54                              'please run "pip install websockets"')
55
56        super().__init__(**kwargs)  # processes 'quiet' and type hints
57
58        host_port = data_server.split(':')
59        if len(host_port) == 1:
60            self.data_server = 'localhost:' + data_server  # they gave us '8766'
61        elif not len(host_port[0]):
62            self.data_server = 'localhost' + data_server   # they gave us ':8766'
63        else:
64            self.data_server = data_server                 # they gave us 'host:8766'
65
66        self.websocket = None
67        self.back_seconds = back_seconds
68        self.cleanup_interval = cleanup_interval
69        self.use_wss = use_wss
70        self.check_cert = check_cert
71        self.event_loop = asyncio.new_event_loop()
72
73        # "loop" parameter removed in Ubuntu 22, but needed in earlier releases
74        try:
75            self.send_queue = asyncio.Queue(maxsize=max_backup)
76        except RuntimeError:
77            self.send_queue = asyncio.Queue(maxsize=max_backup, loop=self.event_loop)
78
79        # Start the thread that will asynchronously pull stuff from the
80        # queue and send to the websocket. Also will, if we've got our oue
81        # data server, run cleanup from time to time.
82        self.cached_data_writer_thread = threading.Thread(
83            name='cached_data_writer_thread',
84            target=self._cached_data_writer_loop, daemon=True)
85        self.cached_data_writer_thread.start()

Feed passed records to a CachedDataServer via a websocket. Expects records in DASRecord or dict formats.

data_server    [host:]port on which to look for data server

back_seconds   Number of seconds of back data to hold in cache

cleanup_interval   Remove old data every N seconds

update_interval    Serve updates to websocket clients every N seconds

max_backup    If the writer isn't able to connect to the data server,
              it will locally cache records until it can. To avoid
              unbounded memory usage, if max_backup is nonzero, it will
              cache at most max_backup records before dropping the
              oldest records. By default, cache one day's worth of
              records at 1 Hz (86,400 records). If max_backup is zero,
              cache size is unbounded.

use_wss -     If True, use secure websockets

check_cert  - If True and use_wss is True, check the server's TLS certificate
              for validity; if a str, use as local filepath location of .pem
              file to check against.

websocket
back_seconds
cleanup_interval
use_wss
check_cert
event_loop
cached_data_writer_thread
def write(self, record: Union[logger.utils.das_record.DASRecord, dict]):
162    def write(self, record: Union[DASRecord, dict]):
163        """Write out record. Expects passed records to be in one of three
164        formats:
165
166        1) DASRecord
167
168        2) a list of DASRecords
169
170        3) a dict encoding optionally a source data_id and timestamp and a
171           mandatory 'fields' key of field_name: value pairs. This is the format
172           emitted by default by ParseTransform:
173
174           {
175             'data_id': ...,
176             'timestamp': ...,
177             'fields': {
178               field_name: value,    # use default timestamp of 'now'
179               field_name: value,
180               ...
181             }
182           }
183
184        A twist on format (3) THAT WE'RE PROBABLY GOING TO PHASE OUT IN
185        FAVOR OF (2) is that the values may either be a singleton (int,
186        float, string, etc) or a list. If the value is a singleton, it is
187        taken at face value. If it is a list, it is assumed to be a list
188        of (value, timestamp) tuples, in which case the top-level
189        timestamp, if any, is ignored.
190
191           {
192             'data_id': ...,
193             'timestamp': ...,
194             'fields': {
195                field_name: [(timestamp, value), (timestamp, value),...],
196                field_name: [(timestamp, value), (timestamp, value),...],
197                ...
198             }
199           }
200
201        """
202        # See if it's something we can process, and if not, try digesting
203        if not self.can_process_record(record):  # inherited from BaseModule()
204            self.digest_record(record)  # inherited from BaseModule()
205            return
206
207        # Convert to a dict - inefficient, I know...
208        if isinstance(record, DASRecord):
209            record = json.loads(record.as_json())
210        if isinstance(record, dict):
211            # If our local queue is full, throw away the oldest entries
212            while self.send_queue.full():
213                try:
214                    logging.debug('CachedDataWriter queue full - dropping oldest...')
215                    self.send_queue.get_nowait()
216                except asyncio.QueueEmpty:
217                    logging.warning('CachedDataWriter queue is both full and empty?!?')
218
219            # Enqueue our latest record for send
220            try:
221                self.send_queue.put_nowait(record)
222            except asyncio.queues.QueueFull:
223                logging.warning('CachedDataWriter unable to write: write queue full')
224        else:
225            if not self.quiet:
226                logging.warning('CachedDataWriter got non-dict/DASRecord object of '
227                                'type %s: %s', type(record), str(record))

Write out record. Expects passed records to be in one of three formats:

1) DASRecord

2) a list of DASRecords

3) a dict encoding optionally a source data_id and timestamp and a mandatory 'fields' key of field_name: value pairs. This is the format emitted by default by ParseTransform:

{ 'data_id': ..., 'timestamp': ..., 'fields': { field_name: value, # use default timestamp of 'now' field_name: value, ... } }

A twist on format (3) THAT WE'RE PROBABLY GOING TO PHASE OUT IN FAVOR OF (2) is that the values may either be a singleton (int, float, string, etc) or a list. If the value is a singleton, it is taken at face value. If it is a list, it is assumed to be a list of (value, timestamp) tuples, in which case the top-level timestamp, if any, is ignored.

{ 'data_id': ..., 'timestamp': ..., 'fields': { field_name: [(timestamp, value), (timestamp, value),...], field_name: [(timestamp, value), (timestamp, value),...], ... } }