openrvdas.logger.writers.cached_data_writer
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))
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
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.
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),...], ... } }