openrvdas.server.cached_data_server
Accept data in DASRecord or dict format via the cache_record() method, then serve it to anyone who connects via a websocket. A CachedDataServer can be instantiated by running this script from the command line and providing one or more UDP ports on which to listen for timestamped text data that it can parse into key:value pairs. It may also be instantiated as part of a CachedDataWriter that can be invoked on the command line via the listen.py script of via a configuration file. The following direct invocation of this script
logger/utils/cached_data_server.py --udp 6225 --port 8766 --disk_cache /var/tmp/openrvdas/disk_cache --back_seconds 3600 --v
says to
- Listen on the UDP port specified by --udp for JSON-encoded, timestamped, field:value pairs. (See the definition for cache_record(), below for formats understood.)
- Store the received data in memory, retaining the most recent 3600
seconds for each field (default is 86400 seconds = 24 hours).
(The total number of values cached per field is also limited by the
max_recordsparameter and defaults to 2880, equivalent to two records per minute for 24 hours. It may be overridden to "infinite" by setting--max_records=0on the command line.) - Periodically back up the in-memory cache to a disk-based cache at /var/tmp/openrvdas/disk_cache (By default, back up every 60 seconds; this can be overridden with the --cleanup_interval argument).
- Wait for clients to connect to the websocket at port 8766 and serve them the requested data. Web clients may issue JSON-encoded requests of the following formats (see the definition of serve_requests() for insight):
{'type':'fields'} - return a list of fields for which cache has data
{'type':'describe',
'fields':['field_1', 'field_2', 'field_3']}
- return a dict of metadata descriptions for each specified field. If
'fields' is omitted, return a dict of metadata for *all* fields
{'type':'subscribe',
'fields':{'field_1':{'seconds':50},
'field_2':{'seconds':0, 'back_records':10},
'field_3':{'seconds':-1}}}
- subscribe to updates for field_1, field_2 and field_3. Allowable
values for 'seconds':
0 - provide only new values that arrive after subscription
-1 - provide the most recent value, and then all future new ones
num - provide num seconds of back data, then all future new ones
If 'seconds' is missing, use '0' as the default.
If 'back_records' is present it must be a number greater than or equal
to zero. If present and non-zero, the CDS will try to provide at least
that many "back records" when it first returns, even if it has to go
back further than the interval specified in 'seconds'.
{'type':'ready'}
- indicate that client is ready to receive the next set of updates
for subscribed fields.
{'type':'publish', 'data':{'timestamp':1555468528.452,
'fields':{'field_1':'value_1',
'field_2':'value_2'}}}
- submit new data to the cache (an alternative way to get data
in without the same record size limits of a UDP packet).
HTTP GET interface (requires websockets >= 12):
In addition to the WebSocket API, the server accepts plain HTTP GET requests on the same port. This allows shell scripts and other simple tools to retrieve the latest cached values without implementing a WebSocket handshake:
GET /fields
Returns a JSON object listing all field names currently in cache:
{"fields": ["field_1", "field_2", ...]}
Example:
curl http://localhost:8766/fields
GET /latest/<field_1>[,<field_2>,...]
Returns the most recent cached timestamp and value for each
requested field as a JSON object:
{"field_1": {"timestamp": 1555468528.452, "value": 21.3},
"field_2": {"timestamp": 1555468530.001, "value": "A"},
"field_3": null} <- null when the field is not in cache
Example (single field):
curl http://localhost:8766/latest/S330Lat
Example (multiple fields):
curl http://localhost:8766/latest/S330Lat,S330Lon,MwxAirTemp
All other plain HTTP requests receive 400 Bad Request. WebSocket connections are unaffected. On systems with websockets < 12 the HTTP routes are unavailable and behaviour is unchanged.
CAUTION: HTTP GET requests run inside the same asyncio event loop as all WebSocket connections. Occasional queries (e.g. populating an elog entry on demand) are fine. High-frequency polling will delay WebSocket data delivery to connected clients; use the WebSocket subscription API for any use case requiring frequent or continuous updates.
1#!/usr/bin/env python3 2 3"""Accept data in DASRecord or dict format via the cache_record() method, 4then serve it to anyone who connects via a websocket. A 5CachedDataServer can be instantiated by running this script from the 6command line and providing one or more UDP ports on which to listen 7for timestamped text data that it can parse into key:value pairs. It 8may also be instantiated as part of a CachedDataWriter that can be 9invoked on the command line via the listen.py script of via a 10configuration file. 11The following direct invocation of this script 12``` 13 logger/utils/cached_data_server.py \ 14 --udp 6225 \ 15 --port 8766 \ 16 --disk_cache /var/tmp/openrvdas/disk_cache \ 17 --back_seconds 3600 \ 18 --v 19``` 20says to 211. Listen on the UDP port specified by --udp for JSON-encoded, 22 timestamped, field:value pairs. (See the definition for cache_record(), 23 below for formats understood.) 242. Store the received data in memory, retaining the most recent 3600 25 seconds for each field (default is 86400 seconds = 24 hours). 26 (The total number of values cached per field is also limited by the 27 ``max_records`` parameter and defaults to 2880, equivalent to two 28 records per minute for 24 hours. It may be overridden to "infinite" 29 by setting ``--max_records=0`` on the command line.) 303. Periodically back up the in-memory cache to a disk-based cache at 31 /var/tmp/openrvdas/disk_cache (By default, back up every 60 seconds; 32 this can be overridden with the --cleanup_interval argument). 334. Wait for clients to connect to the websocket at port 8766 and serve 34 them the requested data. Web clients may issue JSON-encoded 35 requests of the following formats (see the definition of 36 serve_requests() for insight): 37``` 38 {'type':'fields'} - return a list of fields for which cache has data 39 {'type':'describe', 40 'fields':['field_1', 'field_2', 'field_3']} 41 - return a dict of metadata descriptions for each specified field. If 42 'fields' is omitted, return a dict of metadata for *all* fields 43 {'type':'subscribe', 44 'fields':{'field_1':{'seconds':50}, 45 'field_2':{'seconds':0, 'back_records':10}, 46 'field_3':{'seconds':-1}}} 47 - subscribe to updates for field_1, field_2 and field_3. Allowable 48 values for 'seconds': 49 0 - provide only new values that arrive after subscription 50 -1 - provide the most recent value, and then all future new ones 51 num - provide num seconds of back data, then all future new ones 52 If 'seconds' is missing, use '0' as the default. 53 54 If 'back_records' is present it must be a number greater than or equal 55 to zero. If present and non-zero, the CDS will try to provide at least 56 that many "back records" when it first returns, even if it has to go 57 back further than the interval specified in 'seconds'. 58 {'type':'ready'} 59 - indicate that client is ready to receive the next set of updates 60 for subscribed fields. 61 {'type':'publish', 'data':{'timestamp':1555468528.452, 62 'fields':{'field_1':'value_1', 63 'field_2':'value_2'}}} 64 - submit new data to the cache (an alternative way to get data 65 in without the same record size limits of a UDP packet). 66``` 67 68HTTP GET interface (requires websockets >= 12): 69 70In addition to the WebSocket API, the server accepts plain HTTP GET 71requests on the same port. This allows shell scripts and other simple 72tools to retrieve the latest cached values without implementing a 73WebSocket handshake: 74 75``` 76 GET /fields 77 Returns a JSON object listing all field names currently in cache: 78 {"fields": ["field_1", "field_2", ...]} 79 80 Example: 81 curl http://localhost:8766/fields 82 83 GET /latest/<field_1>[,<field_2>,...] 84 Returns the most recent cached timestamp and value for each 85 requested field as a JSON object: 86 {"field_1": {"timestamp": 1555468528.452, "value": 21.3}, 87 "field_2": {"timestamp": 1555468530.001, "value": "A"}, 88 "field_3": null} <- null when the field is not in cache 89 90 Example (single field): 91 curl http://localhost:8766/latest/S330Lat 92 93 Example (multiple fields): 94 curl http://localhost:8766/latest/S330Lat,S330Lon,MwxAirTemp 95``` 96 97All other plain HTTP requests receive 400 Bad Request. WebSocket 98connections are unaffected. On systems with websockets < 12 the HTTP 99routes are unavailable and behaviour is unchanged. 100 101CAUTION: HTTP GET requests run inside the same asyncio event loop as 102all WebSocket connections. Occasional queries (e.g. populating an elog 103entry on demand) are fine. High-frequency polling will delay WebSocket 104data delivery to connected clients; use the WebSocket subscription API 105for any use case requiring frequent or continuous updates. 106""" 107import asyncio 108import json 109import logging 110import os 111import os.path 112import re 113import threading 114import time 115 116try: 117 import websockets 118 try: 119 from websockets.exceptions import ConnectionClosed 120 except ImportError: 121 from websockets.connection import ConnectionClosed 122 # websockets 12+ (asyncio server) exposes http11.Response for process_request handlers 123 try: 124 from websockets.http11 import Response as _WsResponse, Headers as _WsHeaders 125 _WEBSOCKETS_HAS_HTTP11 = True 126 except ImportError: 127 _WEBSOCKETS_HAS_HTTP11 = False 128except ModuleNotFoundError: 129 raise ModuleNotFoundError('CachedDataServer requires websockets module.\n' 130 'Please run "pip3 install websockets".') 131 132from logger.utils.stderr_logging import StdErrLoggingHandler, DEFAULT_LOGGING_FORMAT # noqa: E402 133from logger.utils.das_record import DASRecord # noqa: E402 134 135logging.basicConfig(format=DEFAULT_LOGGING_FORMAT) 136 137 138############################ 139class RecordCache: 140 """Structure for storing/retrieving record data and metadata.""" 141 142 def __init__(self): 143 """ 144 In-memory storage for key:value pairs. 145 """ 146 self.data = {} 147 self.data_lock = threading.Lock() # When operating on whole dict 148 149 self.metadata = {} 150 self.metadata_lock = threading.Lock() 151 152 # Disk files we've tried to write to but failed. 153 self.failed_files = set() 154 155 # Create a lock for each key so threads don't step on each other 156 self.locks = {key: threading.Lock() for key in self.keys()} 157 158 ############################ 159 def cache_record(self, record): 160 """Add the passed record to the cache. 161 Expects passed records to be in one of two formats: 162 1) DASRecord 163 2) A dict encoding optionally a source data_id and timestamp and a 164 mandatory 'fields' key of field_name: value pairs. This is the format 165 emitted by default by ParseTransform: 166 ``` 167 { 168 'data_id': ..., # optional 169 'timestamp': ..., # optional - use time.time() if missing 170 'fields': { 171 field_name: value, 172 field_name: value, 173 ... 174 } 175 } 176 ``` 177 A twist on format (2) is that the values may either be a singleton 178 (int, float, string, etc) or a list. If the value is a singleton, 179 it is taken at face value. If it is a list, it is assumed to be a 180 list of (value, timestamp) tuples, in which case the top-level 181 timestamp, if any, is ignored. 182 ``` 183 { 184 'data_id': ..., # optional 185 'fields': { 186 field_name: [(timestamp, value), (timestamp, value),...], 187 field_name: [(timestamp, value), (timestamp, value),...], 188 ... 189 } 190 } 191 ``` 192 In addition to a 'fields' field, a record may contain a 'metadata' 193 field. If present, the data server will look for a 'fields' dict 194 inside the metadata dict and add the key-value pairs there to its 195 cache of metadata about the fields: 196 ``` 197 {'data_id': 's330', 198 'fields': {'S330CourseMag': 244.29, 199 'S330CourseTrue': 219.61, 200 'S330Mode': 'A', 201 'S330SpeedKm': 16.5, 202 'S330SpeedKt': 8.9}, 203 'metadata': {'fields': { 204 'S330CourseMag': {'description': 'Magnetic course', 205 'device': 's330', 206 'device_type': 'Seapath330', 207 'device_type_field': 'CourseMag', 208 'units': 'degrees'}, 209 'S330CourseTrue': {'description': 'True course', 210 ...} 211 } 212 }} 213 ``` 214 This metadata field will be generated sent at intervals by a 215 RecordParser (and its enclosing ParseTransform) if the parser's 216 ``metadata_interval`` value is not None. 217 """ 218 logging.debug('cache_record() received: %s', record) 219 if not record: 220 logging.debug('cache_record() received empty record.') 221 return 222 223 # If we've been passed a DASRecord, the field:value pairs are in a 224 # field called, uh, 'fields'; if we've been passed a dict, look 225 # for its 'fields' key. 226 if isinstance(record, DASRecord): 227 record_timestamp = record.timestamp 228 fields = record.fields 229 metadata = record.metadata 230 elif isinstance(record, dict): 231 record_timestamp = record.get('timestamp', time.time()) 232 fields = record.get('fields') 233 metadata = record.get('metadata') 234 if fields is None: 235 logging.debug( 236 'Dict record passed to cache_record() has no ' 237 '"fields" key, which either means it\'s not a dict ' 238 'you should be passing, or it is in the old "field_dict" ' 239 'format that assumes key:value pairs are at the top ' 240 'level.') 241 logging.debug('The record in question: %s', str(record)) 242 return 243 else: 244 logging.warning( 245 'Received non-DASRecord, non-dict input (type: %s): %s', 246 type(record), 247 record) 248 return 249 250 # Add values from record to cache 251 for field, value in fields.items(): 252 if field not in self.locks: 253 self.locks[field] = threading.Lock() 254 with self.locks[field]: 255 if field not in self.data: 256 self.data[field] = [] 257 258 if isinstance(value, list): 259 # Okay, for this field we have a list of values - iterate 260 # through 261 for val in value: 262 # If element in the list is itself a list or a tuple, 263 # we'll assume it's a (timestamp, value) pair. Otherwise, 264 # use the default timestamp of 'now'. 265 if type(val) in [list, tuple]: 266 self._add_tuple(field, val) 267 else: 268 self._add_tuple(field, (record_timestamp, value)) 269 else: 270 # If type(value) is *not* a list, assume it's the value 271 # itself. Add it using the default timestamp. 272 self._add_tuple(field, (record_timestamp, value)) 273 274 # Is there any metadata to add? Cache whatever is in the 275 # metadata.data.fields dict. Blithely overwrite whatever might 276 # be there already. 277 if metadata: 278 metadata_fields = metadata.get('fields', {}) 279 with self.metadata_lock: 280 for mfield, value in metadata_fields.items(): 281 self.metadata[mfield] = value 282 283 ############################ 284 def _add_tuple(self, field, value_tuple): 285 self.data[field].append(value_tuple) 286 287 ############################ 288 def keys(self): 289 """Return a list of all keys in the cache.""" 290 return list(self.data.keys()) 291 292 ############################ 293 def get_metadata(self, fields=None): 294 """Return a dict of metadata for the specified list of fields. If no 295 fields are specified, return metadata for all fields. 296 """ 297 with self.metadata_lock: 298 if fields: 299 return { 300 field: self.metadata.get( 301 field, {}) for field in fields} 302 else: 303 return self.metadata 304 305 ############################ 306 def cleanup(self, oldest=0, max_records=0, min_back_records=0): 307 """Remove any data from cache with a timestamp older than 'oldest' 308 seconds, but keep at least one (most recent) value. 309 If max_records is non-zero, truncate to that many of the most 310 recent records. Always, though, keep at least min_back_records. 311 """ 312 logging.debug('Cleaning up cache') 313 fields = self.keys() 314 for field in fields: 315 if field not in self.locks: 316 self.locks[field] = threading.Lock() 317 with self.locks[field]: 318 value_list = self.data[field] 319 320 if len(value_list) <= min_back_records: 321 continue 322 323 # If max_records is specified, truncate to keep that many of 324 # most recent records. 325 if max_records > min_back_records and len(value_list) > max_records: 326 value_list = value_list[-max_records:] 327 328 # Iterate until find value that's not too old, but leave at least 329 # min_back_records. 330 for i in range(len(value_list) - min_back_records): 331 if value_list[i][0] > oldest: 332 break 333 334 # But keep at least one value 335 last_index = min(i, len(value_list) - 1) 336 self.data[field] = value_list[last_index:] 337 338 ############################ 339 def save_to_disk(self, disk_cache): 340 """Create one JSON-encoded cache file per field in the directory named 341 by disk_cache. 342 """ 343 logging.debug('Saving to cache.') 344 if not disk_cache: 345 logging.warning('save_to_disk called, but no disk_cache defined') 346 return 347 348 if not os.path.exists(disk_cache): 349 try: 350 os.makedirs(disk_cache) 351 except OSError as e: 352 logging.error('Unable to create disk cache directory "%s": %s', disk_cache, e) 353 return 354 355 fields = self.keys() 356 for field in fields: 357 disk_filename = disk_cache + '/' + field 358 if disk_filename in self.failed_files: 359 continue 360 if field not in self.locks: 361 self.locks[field] = threading.Lock() 362 with self.locks[field]: 363 try: 364 with open(disk_filename, 'w') as cache_file: 365 json.dump(self.data[field], cache_file) 366 except (PermissionError, IOError, OSError) as e: 367 logging.warning('Unable to write disk cache file %s: %s', disk_filename, e) 368 self.failed_files.add(disk_filename) 369 370 # This is BAD practice; but use it to figure out what else might go wrong 371 # so we can add it to specific exceptions above.s 372 except Exception as e: 373 logging.warning('Unanticipated exception writing disk cache file %s: %s', 374 disk_filename, e) 375 self.failed_files.add(disk_filename) 376 377 ############################ 378 def load_from_disk(self, disk_cache): 379 """Load the data dict from directory of JSON-encoded cache files. 380 """ 381 logging.info('Loading from disk at %s', disk_cache) 382 if not disk_cache: 383 logging.info('load_from_disk called, but no disk_cache defined') 384 return 385 try: 386 if not os.path.exists(disk_cache): 387 logging.info('load_from_disk: no cache found at "%s"', disk_cache) 388 return 389 390 field_files = [f for f in os.listdir(disk_cache) 391 if os.path.isfile(os.path.join(disk_cache, f))] 392 logging.debug('Got cached fields: %s', field_files) 393 for field in field_files: 394 if field not in self.locks: 395 self.locks[field] = threading.Lock() 396 try: 397 with self.locks[field]: 398 with open(disk_cache + '/' + field, 'r') as cache_file: 399 self.data[field] = json.load(cache_file) 400 401 except (json.decoder.JSONDecodeError, UnicodeDecodeError): 402 logging.warning('Failed to parse cache for %s', field) 403 except OSError as e: 404 logging.error('Unable to access disk cache at %s: %s', disk_cache, e) 405 406 407############################ 408class WebSocketConnection: 409 """Handle the websocket connection, serving data as requested.""" 410 ############################ 411 412 def __init__(self, websocket, cache, interval): 413 self.websocket = websocket 414 self.cache = cache 415 self.interval = interval 416 self.quit_flag = False 417 418 ############################ 419 def closed(self): 420 """Has our client closed the connection?""" 421 return self.quit_flag 422 423 ############################ 424 def quit(self): 425 """Close the connection from our end and quit.""" 426 self.quit_flag = True 427 428 ############################ 429 def get_matching_field_names(self, field_name): 430 """If a wildcard field is present, returns a list 431 (matching_field_names) of all the fields that match the 432 pattern. Otherwise, it just returns the field_name as the sole 433 entry in the list. 434 435 field_name - the name of the field as specified in the subscription request 436 """ 437 438 matching_field_names = set() 439 440 # If the field name is a wildcard 441 if '*' in field_name: 442 field_name = field_name.replace("*", ".+") 443 444 for field in self.cache.keys(): 445 if re.search(field_name, field): 446 matching_field_names.add(field) 447 448 # If here, the field name is not a wildcard 449 else: 450 matching_field_names.add(field_name) 451 452 return list(matching_field_names) 453 454 ############################ 455 456 async def send_json_response(self, response, is_error=False): 457 logging.debug('CachedDataServer sending %d bytes', 458 len(json.dumps(response))) 459 await self.websocket.send(json.dumps(response)) 460 if is_error: 461 logging.warning(response) 462 463 ############################ 464 async def serve_requests(self): 465 """Wait for requests and serve data, if it exists, from 466 cache. Requests are in JSON with request type encoded in 467 'request_type' field. Recognized request types are: 468 ``` 469 fields - return a (JSON encoded) list of fields for which cache 470 has data. 471 describe - return a (JSON encoded) dict of metadata for the listed 472 fields. 473 publish - look for a field called 'data' and expect its value to 474 be a dict containing data in one of the formats accepted by 475 cache_record(). 476 subscribe - look for a field called 'fields' in the request whose 477 value is a dict of the format 478 ``` 479 {field_name:{seconds:600, back_records:10}, 480 field_name:{seconds:0},...} 481 ``` 482 The entire specification may also have a field called 483 'interval', specifying how often server should provide 484 updates. Will default to what was specified on command line 485 with --interval flag (which itself defaults to 1 second 486 intervals). 487 ``` 488 ``` 489 A subscription will instruct the CachedDataServer to begin 490 serving JSON messages of the format 491 ``` 492 { 493 field_name: [(timestamp, value), (timestamp, value),...], 494 field_name: [(timestamp, value), (timestamp, value),...], 495 field_name: [(timestamp, value), (timestamp, value),...], 496 } 497 ``` 498 Initially provide the number of seconds worth of back data 499 requested, and on subsequent calls, return all data that have 500 arrived since last call. 501 NOTE: if the 'seconds' field is -1, server will only ever provide 502 the single most recent value for the relevant field. 503 ready - client has processed the previous data message and is ready 504 for more. 505 ``` 506 """ 507 # The field details specified in a subscribe request 508 requested_fields = {} 509 510 # A map from field_name:latest_timestamp_sent. If latest_timestamp_sent is -1 511 # then we'll always send just the most recent value we have for the field, 512 # regardless of how many there are, or whether we've sent it before. 513 field_timestamps = {} 514 515 # Output format requested by the client; set by a subscribe message. 516 # Default matches the subscribe handler's own default so a 'ready' 517 # received before any 'subscribe' degrades gracefully. 518 requested_format = 'field_dict' 519 520 interval = self.interval # Use the default interval, uh, by default 521 522 while not self.quit_flag: 523 now = time.time() 524 try: 525 logging.debug('Waiting for client') 526 raw_request = await self.websocket.recv() 527 request = json.loads(raw_request) 528 529 # Make sure we've received a dict 530 if not isinstance(request, dict): 531 await self.send_json_response( 532 {'status': 400, 'error': 'non-dict request received'}, 533 is_error=True) 534 535 # Make sure request dict has a 'type' field 536 elif 'type' not in request: 537 await self.send_json_response( 538 {'status': 400, 'error': 'no "type" field found in request'}, 539 is_error=True) 540 541 # Let's see what type of request it is 542 543 # Send client a list of the variable names we're able to serve. 544 elif request['type'] == 'fields': 545 logging.debug('fields request') 546 await self.send_json_response( 547 {'type': 'fields', 'status': 200, 548 'data': self.cache.keys()}) 549 550 # Send client a dict of metadata descriptions; if they've 551 # specified a set of fields, give just metadata for those; 552 # otherwise send everything. 553 elif request['type'] == 'describe': 554 logging.debug('describe request') 555 fields = request.get('fields') 556 result = self.cache.get_metadata(fields) 557 await self.send_json_response( 558 {'type': 'describe', 'status': 200, 'data': result}) 559 560 # Client wants to publish to cache and provides a dict of data 561 elif request['type'] == 'publish': 562 logging.debug('publish request') 563 data = request.get('data') 564 if data is None: 565 await self.send_json_response( 566 {'type': 'publish', 'status': 400, 567 'error': 'no data field found in request'}, 568 is_error=True) 569 elif not isinstance(data, dict): 570 await self.send_json_response( 571 {'type': 'publish', 'status': 400, 572 'error': 'request has non-dict data field'}, 573 is_error=True) 574 else: 575 self.cache.cache_record(data) 576 await self.send_json_response({'type': 'publish', 'status': 200}) 577 578 # Client wants to subscribe, and provides a dict of requested 579 # fields 580 elif request['type'] == 'subscribe': 581 logging.debug('subscribe request') 582 # Have they given us a new subscription interval? 583 requested_interval = request.get('interval') 584 if requested_interval is not None: 585 try: 586 interval = float(requested_interval) 587 except ValueError: 588 await self.send_json_response( 589 {'type': 'subscribe', 'status': 400, 590 'error': 'non-numeric interval requested'}, 591 is_error=True) 592 continue 593 594 # Which fields do they want? 595 raw_requested_fields = request.get('fields') 596 if not raw_requested_fields: 597 await self.send_json_response( 598 {'type': 'subscribe', 'status': 400, 599 'error': 'no fields found in subscribe request'}, 600 is_error=True) 601 continue 602 603 # What format do they want output in? field_dict? 604 # record_list? By default, use field_dict. 605 requested_format = request.get('format', 'field_dict') 606 607 # Parse out request field names and number of back seconds 608 # requested. Encode that as 'last timestamp sent', unless back 609 # seconds == -1. If -1, save it as -1, so that we know we're 610 # always just sending the the most recent field value. Stores 611 # all fields, including expanded entries from a wildcard, in the 612 # requested_fields dict. 613 614 now = time.time() 615 616 # Reset requested field_timestamps and field_back_records 617 requested_fields = {} 618 field_timestamps = {} # last timestamp seen 619 620 logging.debug('Subscription requested') 621 for field_name, field_spec in raw_requested_fields.items(): 622 matching_field_names = self.get_matching_field_names(field_name) 623 624 for matching_field_name in matching_field_names: 625 requested_fields[matching_field_name] = field_spec 626 # If we don't have a field spec dict 627 if isinstance(field_spec, dict): 628 back_records = field_spec.get('back_records', 0) 629 back_seconds = field_spec.get('seconds', 0) 630 else: 631 back_records = 0 632 back_seconds = 0 633 634 # Now figure out what's the latest timestamp we have for this 635 # field name that respects the back_records and back_seconds 636 # specification. 637 field_timestamps[matching_field_name] = 0 # if nothing else 638 639 if field_name not in self.cache.locks: 640 logging.debug('No data for requested field %s', matching_field_name) 641 continue 642 with self.cache.locks[field_name]: 643 field_cache = self.cache.data.get(matching_field_name) 644 if field_cache is None: 645 logging.debug('No cached data for %s', matching_field_name) 646 continue 647 648 logging.debug(' %s: %d records available; %d requested, ' 649 '%d seconds', matching_field_name, len(field_cache), 650 back_records, back_seconds) 651 # If no data for requested field, skip. 652 if not field_cache or not field_cache[-1]: 653 continue 654 655 # If special case 0, they only want records that come after 656 # this point in time. Set the last timestamp seen as the 657 # most-recently seen timestamp, or zero if no entries. 658 if back_seconds == 0: 659 if len(field_cache) > 0: 660 field_timestamps[matching_field_name] = field_cache[-1][0] 661 else: 662 field_timestamps[matching_field_name] = 0 663 continue 664 665 # If special case -1, they want just single most recent 666 # value. Set the last timestamp seen as the second to last 667 # timestamp if multiple entries, or as zero, if only 1. 668 if back_seconds == -1: 669 if len(field_cache) > 1: 670 field_timestamps[matching_field_name] = field_cache[-2][0] 671 else: 672 field_timestamps[matching_field_name] = 0 673 continue 674 675 # We've been told to return at least 'back_records' records; if 676 # there aren't at least that many, leave field_timestamps[field] 677 # at zero to return all we've got. 678 if len(field_cache) <= back_records: 679 continue 680 681 # If here, we've got at least 'back_records' records, and want to 682 # search backward to include the last 'back_seconds' seconds of 683 # them. Could do more efficiently with some sort of binary search. 684 this_record_index = len(field_cache) - back_records - 1 685 while this_record_index >= 0: 686 # Recall that each element is (timestamp, value) 687 this_timestamp = field_cache[this_record_index][0] 688 689 if now - this_timestamp > back_seconds: 690 # Set our 'last seen' timestamp as timestamp of previous 691 # record and stop looking. 692 prev_timestamp = field_cache[this_record_index-1][0] 693 field_timestamps[matching_field_name] = prev_timestamp 694 break 695 this_record_index -= 1 696 697 if raw_requested_fields and not requested_fields: 698 logging.info('Request doesn\'t match any existing fields') 699 700 # Let client know request succeeded 701 await self.send_json_response({'type': 'subscribe', 'status': 200}) 702 703 # Client just letting us know it's ready for more. If there are 704 # fields that have been requested, send along any new data for 705 # them. 706 elif request['type'] == 'ready': 707 logging.debug('Websocket got ready...') 708 if not field_timestamps: 709 # Client has told us that they're ready, but there are no 710 # fields that match their request. Let them know, then 711 # pause a moment before we try fielding their next 712 # request. 713 await self.send_json_response( 714 {'type': 'ready', 'status': 400, 715 'error': 'client ready, but no matching fields found (yet).'}, 716 is_error=False) 717 await asyncio.sleep(self.interval * 5) 718 719 ########## 720 results = {} 721 if requested_format == 'field_dict': 722 for field_name, field_spec in requested_fields.items(): 723 if field_name not in self.cache.locks: 724 logging.debug('No data for requested field %s', field_name) 725 continue 726 727 with self.cache.locks[field_name]: 728 field_cache = self.cache.data.get(field_name) 729 if field_cache is None: 730 logging.debug( 731 'No cached data for %s', field_name) 732 continue 733 734 # If no data for requested field, skip. 735 if not field_cache or not field_cache[-1]: 736 continue 737 738 # If special case -1, they want just single most recent 739 # value, then future results. Grab last value, then set its 740 # timestamp as the last one we've seen. 741 back_seconds = field_spec.get('back_seconds', 0) 742 if back_seconds == -1: 743 last_value = field_cache[-1] 744 results[field_name] = [last_value] 745 # ts of last value 746 field_timestamps[field_name] = last_value[0] 747 continue 748 749 # Otherwise - if no data newer than the latest 750 # timestamp we've already sent, skip, 751 latest_timestamp = field_timestamps.get(field_name, 0) 752 if not field_cache[-1][0] > latest_timestamp: 753 continue 754 755 # Otherwise, copy over records arrived since 756 # latest_timestamp and update the latest_timestamp sent 757 # (first element of last pair in field_cache). 758 field_results = [ 759 pair for pair in field_cache if pair[0] > latest_timestamp] 760 results[field_name] = field_results 761 if field_results: 762 field_timestamps[field_name] = field_results[-1][0] 763 764 ########## 765 # If not outputting data as a field dict, output as a list 766 # of records. 767 elif requested_format == 'record_list': 768 records = {} 769 for field_name, field_spec in requested_fields.items(): 770 if field_name not in self.cache.locks: 771 logging.debug( 772 'No data for requested field %s', field_name) 773 continue 774 with self.cache.locks[field_name]: 775 latest_timestamp = field_timestamps.get( 776 field_name, 0) 777 field_cache = self.cache.data.get( 778 field_name, None) 779 780 if not field_cache or not field_cache[-1]: 781 logging.debug( 782 'No cached data for %s', field_name) 783 continue 784 785 # If latest_timestamp is special case -1, they want just 786 # single most recent value, then future results. Grab 787 # last value, then set its timestamp as the last one 788 # we've seen. 789 elif latest_timestamp == -1: 790 last_ts, last_value = field_cache[-1] 791 if last_ts not in records: 792 records[last_ts] = {} 793 records[last_ts][field_name] = last_value 794 field_timestamps[field_name] = last_ts 795 continue 796 797 # Otherwise - if no data newer than the latest 798 # timestamp we've already sent, skip, 799 elif not field_cache[-1][0] > latest_timestamp: 800 continue 801 802 # Otherwise, copy over records arrived since 803 # latest_timestamp and update the latest_timestamp sent 804 # (first element of last pair in field_cache). 805 else: 806 # Get the new (ts, value) pairs for this 807 # field 808 field_results = [ 809 pair for pair in field_cache if pair[0] > latest_timestamp] 810 811 # We know field_results is non-empty because of previous 812 # elif, so new latest timestamp is last ts 813 # in it. 814 field_timestamps[field_name] = field_results[-1][0] 815 816 # Collate values by timestamp, folding into values for 817 # other fields. 818 for ts, value in field_results: 819 if ts not in records: 820 records[ts] = {} 821 records[ts][field_name] = value 822 823 # Create and send a list with one DASRecord-like dict for 824 # each timestamp. 825 results = [{'timestamp': ts, 'fields': records[ts]} 826 for ts in sorted(records)] 827 828 # If unknown requested format 829 else: 830 mesg = ( 831 'Unrecognized requested format: %s; valid formats are ' 832 '"field_dict" and "record_list"' % 833 requested_format) 834 logging.warning(mesg) 835 await self.send_json_response({'status': 400, 'error': mesg}, is_error=True) 836 837 logging.debug( 838 'Websocket results: %s...', 839 str(results)[ 840 0:100]) 841 842 # Package up what results we have (if any) and send them 843 # off 844 await self.send_json_response({'type': 'data', 'status': 200, 845 'data': results}) 846 847 # New results or not, take a nap before trying to fetch 848 # more results 849 elapsed = time.time() - now 850 time_to_sleep = max(0, interval - elapsed) 851 logging.debug('Sleeping %g seconds', time_to_sleep) 852 await asyncio.sleep(time_to_sleep) 853 854 # If unrecognized request type - whine, then iterate 855 else: 856 await self.send_json_response( 857 {'status': 400, 858 'error': 'unrecognized request type: %s' % request['type']}, 859 is_error=True) 860 861 # If we got bad input, complain and loop 862 except json.JSONDecodeError: 863 await self.send_json_response( 864 {'status': 400, 'error': 'received unparseable JSON'}, 865 is_error=True) 866 logging.warning('unparseable JSON: %s', raw_request) 867 868 # If our connection closed, complain and exit gracefully 869 except ConnectionClosed: 870 logging.info('Client closed connection') 871 self.quit() 872 873 874########################################################################## 875class CachedDataServer: 876 """Class that caches field:value pairs passed to it in either a 877 DASRecord or a simple dict. It also establishes a websocket server 878 on the specified port and serves the cached values to clients that 879 connect via a websocket. 880 881 WebSocket API: 882 883 The server listens for two types of requests: 884 1. If the request is the string "variables", return a list of the 885 names of the variables the server has in cache and is able to 886 serve. The server will continue listening for follow up messages, 887 most likely this one: 888 2. If the request is a python dict, assume it is of the form: 889 ``` 890 {field_1_name: {'seconds': num_secs}, 891 field_2_name: {'seconds': num_secs}, 892 ...} 893 ``` 894 where seconds is a float representing the number of seconds of 895 back data being requested. 896 This field dict is passed to serve_fields(), which will to retrieve 897 num_secs of back data for each of the specified fields and return it 898 as a JSON-encoded dict of the form: 899 ``` 900 { 901 field_1_name: [(timestamp, value), (timestamp, value), ...], 902 field_2_name: [(timestamp, value), (timestamp, value), ...], 903 ... 904 } 905 ``` 906 The server will then await a "ready" message from the client, and when 907 received, will loop and send a JSON-encoded dict of all the 908 (timestamp, value) tuples that have come in since the previous 909 request. It will continue this behavior indefinitely, waiting for a 910 "ready" request and sending updates. 911 912 HTTP GET API (requires websockets >= 12): 913 914 Plain HTTP GET requests are also accepted on the same port, allowing 915 simple tools to retrieve the latest cached value for one or more 916 fields without a WebSocket handshake: 917 918 GET /fields 919 Returns {"fields": ["field_1", "field_2", ...]} 920 921 GET /latest/<field_1>[,<field_2>,...] 922 Returns {"field_1": {"timestamp": T, "value": V}, ...} 923 Fields not present in the cache are returned as null. 924 925 CAUTION: HTTP GET requests are handled inside the same asyncio event 926 loop that drives all WebSocket connections. Each request briefly 927 acquires the cache's threading lock, blocking the loop for the 928 duration of the lookup. For occasional one-off queries (e.g. populating 929 an elog entry) this is negligible. High-frequency polling from scripts 930 or automated tools will delay WebSocket data delivery to all connected 931 clients. Use the WebSocket subscription API for any use case that 932 requires frequent or continuous updates. 933 """ 934 935 ############################ 936 def __init__( 937 self, 938 port, 939 interval=1, 940 back_seconds=60 * 60, 941 max_records=60 * 24, 942 min_back_records=100, 943 cleanup_interval=60, 944 disk_cache=None): 945 """ 946 port Port on which to serve websocket connections 947 interval How frequently to serve updates 948 back_seconds 949 How many seconds of back data to retain 950 max_records 951 Maximum number of records to store for each variable 952 min_back_records 953 Minimum number of back records to keep when purging old data 954 cleanup_interval 955 How many seconds between calls to cleanup old cache entries 956 and save to disk (if disk_cache is specified) 957 disk_cache If not None, name of directory in which to backup values 958 from in-memory cache 959 """ 960 self.port = port 961 self.interval = interval 962 self.back_seconds = back_seconds 963 self.max_records = max_records 964 self.min_back_records = min_back_records 965 self.cleanup_interval = cleanup_interval 966 967 self.cache = RecordCache() 968 969 # If they've given us the name of a disk cache, try loading our 970 # RecordCache from it. 971 self.disk_cache = disk_cache 972 if disk_cache: 973 self.cache.load_from_disk(disk_cache) 974 975 # List where we'll store our websocket connections so that we can 976 # keep track of which are still open, and signal them to close 977 # when we're done. 978 self._connections = [] 979 self._connection_lock = threading.Lock() 980 981 self.quit_flag = False 982 983 # Start a thread to loop through, cleaning up the cache and (if we've 984 # been given a disk_cache file) backing memory up to it. 985 threading.Thread(target=self.cleanup_loop, daemon=True).start() 986 987 # Fire up the thread that's going to the websocket server in our 988 # event loop. Calling quit() it will close any remaining 989 # connections and stop the event loop, terminating the server. 990 self.server_thread = threading.Thread( 991 target=self._start_event_loop, daemon=True) 992 self.server_thread.start() 993 994 def _start_event_loop(self): 995 """Initialize and run the asyncio event loop in the current thread.""" 996 try: 997 self.event_loop = asyncio.new_event_loop() 998 asyncio.set_event_loop(self.event_loop) # Bind the loop to this thread 999 self._run_websocket_server() 1000 except Exception as e: 1001 logging.error('Failed to start event loop: %s', str(e)) 1002 1003 ############################ 1004 def __del__(self): 1005 if self.event_loop: 1006 self.event_loop.stop() 1007 self.event_loop.close() 1008 1009 ############################ 1010 def cache_record(self, record): 1011 """Cache the passed record.""" 1012 self.cache.cache_record(record) 1013 1014 ############################ 1015 def cleanup_loop(self): 1016 """Clear out records older than oldest seconds.""" 1017 while not self.quit_flag: 1018 time.sleep(self.cleanup_interval) 1019 1020 # What's the oldest record we should retain? 1021 oldest = time.time() - self.back_seconds 1022 self.cache.cleanup(oldest=oldest, max_records=self.max_records, 1023 min_back_records=self.min_back_records) 1024 1025 # If we're using a disk cache, save things now 1026 if self.disk_cache: 1027 self.cache.save_to_disk(self.disk_cache) 1028 1029 ############################ 1030 def _run_websocket_server(self): 1031 """Start serving on the specified websocket.""" 1032 async def start_server(): 1033 logging.info('Starting WebSocketServer on port %d', self.port) 1034 try: 1035 extra_kwargs = {} 1036 if _WEBSOCKETS_HAS_HTTP11: 1037 # Handle plain HTTP GET requests on the WebSocket port. 1038 # Supports two routes for simple data retrieval without a 1039 # full WebSocket handshake (see issue #367): 1040 # 1041 # GET /fields 1042 # Returns JSON list of all cached field names. 1043 # Example: {"fields": ["Temp", "Pressure", ...]} 1044 # 1045 # GET /latest/<field1>[,<field2>,...] 1046 # Returns the most recent timestamp+value for each 1047 # requested field. 1048 # Example: {"Temp": {"timestamp": 1234567890.0, 1049 # "value": 21.3}, 1050 # "Pressure": null} <- null if not cached 1051 # 1052 # All other non-WebSocket requests receive a 400 response. 1053 # Proxy warnings are preserved from the original handler. 1054 async def _handle_non_ws_request(connection, request): 1055 upgrade = request.headers.get('Upgrade', '') 1056 if upgrade.lower() == 'websocket': 1057 return None # let normal WebSocket upgrade proceed 1058 1059 via = request.headers.get('Via', '') 1060 if via: 1061 logging.warning( 1062 'WebSocket upgrade on port %d was blocked by an HTTP ' 1063 'proxy (Via: %s) which stripped the Upgrade header. ' 1064 'WebSocket connections cannot be established through ' 1065 'this proxy. Fix: enable SSL/WSS, configure the proxy ' 1066 'to pass WebSocket upgrades, or bypass the proxy for ' 1067 'this host.', 1068 self.port, via) 1069 1070 path = request.path 1071 1072 if path == '/fields': 1073 body = json.dumps( 1074 {'fields': sorted(self.cache.keys())} 1075 ).encode() 1076 return _WsResponse( 1077 200, 'OK', 1078 _WsHeaders([('Content-Type', 'application/json')]), 1079 body) 1080 1081 if path.startswith('/latest/'): 1082 field_names = path[len('/latest/'):].split(',') 1083 result = {} 1084 with self.cache.data_lock: 1085 for field in field_names: 1086 entries = self.cache.data.get(field) 1087 if entries: 1088 timestamp, value = entries[-1] 1089 result[field] = { 1090 'timestamp': timestamp, 1091 'value': value, 1092 } 1093 else: 1094 result[field] = None 1095 body = json.dumps(result).encode() 1096 return _WsResponse( 1097 200, 'OK', 1098 _WsHeaders([('Content-Type', 'application/json')]), 1099 body) 1100 1101 logging.debug( 1102 'Plain HTTP request on WebSocket port %d ' 1103 '(path %r is not a recognised CDS HTTP route). ' 1104 'Check nginx proxy_set_header Upgrade config.', 1105 self.port, path) 1106 return _WsResponse( 1107 400, 'Bad Request', 1108 _WsHeaders([('Content-Type', 'text/plain')]), 1109 b'Expected a WebSocket upgrade or a recognised CDS ' 1110 b'HTTP route (/fields, /latest/<field,...>)\n') 1111 1112 extra_kwargs['process_request'] = _handle_non_ws_request 1113 1114 self.websocket_server = await websockets.serve( 1115 self._serve_websocket_data, 1116 host='', 1117 port=self.port, 1118 **extra_kwargs 1119 ) 1120 logging.info('WebSocket server running on port %d', self.port) 1121 await self.websocket_server.wait_closed() 1122 except OSError as e: 1123 logging.fatal('Failed to open websocket on port %d: %s', self.port, e) 1124 raise e 1125 1126 # Use asyncio.run to manage the event loop 1127 asyncio.run(start_server()) 1128 1129 ############################ 1130 def quit(self): 1131 """Exit the loop and shut down all loggers. 1132 """ 1133 # Close any connections 1134 with self._connection_lock: 1135 self.quit_flag = True 1136 1137 for connection in self._connections: 1138 connection.quit() 1139 logging.info('WebSocketServer closed') 1140 1141 # Stop the event loop that's serving connections 1142 self.event_loop.stop() 1143 1144 # Wait for thread that's running the server to finish 1145 self.server_thread.join() 1146 1147 ############################ 1148 """Top-level coroutine for running CachedDataServer.""" 1149 async def _serve_websocket_data(self, websocket, unused_loop_arg=None): 1150 # Legacy websocket code passes in event loop as third argument; we don't need it, 1151 # but include so code works both pre and post WS14. 1152 1153 # Here is where we see the anomalous behavior - when constructed 1154 # directly, self.cache is as it should be: a shared cache. But 1155 # when invoked indirectly, e.g. as part of a listener via 1156 # 1157 # listener = ListenerFromLoggerConfig(config) 1158 # proc = multiprocessing.Process(target=listener.run, daemon=True) 1159 # proc.start() 1160 # 1161 # then self.cache always appears ins in its initial (empty) state. 1162 connection = WebSocketConnection(websocket, self.cache, self.interval) 1163 1164 # Stash the connection so we can tell it to exit when we receive a 1165 # quit(). But first do some cleanup, getting rid of old 1166 # connections that have closed. 1167 with self._connection_lock: 1168 index = 0 1169 while index < len(self._connections): 1170 if self._connections[index].closed(): 1171 logging.debug('Disposing of closed connection.') 1172 self._connections.pop(index) 1173 else: 1174 index += 1 1175 # Now add the new connection 1176 self._connections.append(connection) 1177 1178 # If client disconnects, tell connection to quit 1179 try: 1180 await connection.serve_requests() 1181 except ConnectionClosed: 1182 logging.warning('client disconnected') 1183 except KeyboardInterrupt: 1184 logging.warning('Keyboard Interrupt') 1185 1186 connection.quit() 1187 await websocket.close() 1188 1189 1190########################################################################## 1191########################################################################## 1192if __name__ == '__main__': 1193 import argparse 1194 1195 from logger.readers.composed_reader import ComposedReader 1196 from logger.readers.udp_reader import UDPReader 1197 from logger.transforms.from_json_transform import FromJSONTransform 1198 1199 parser = argparse.ArgumentParser() 1200 parser.add_argument('--port', dest='port', required=True, 1201 action='store', type=int, 1202 help='Websocket port on which to serve data') 1203 1204 parser.add_argument('--udp', dest='udp', default=None, action='store', 1205 help='Comma-separated list of network ports to listen ' 1206 'for data on, e.g. 6221,6224. Prefix by group id ' 1207 'to specify multicast.') 1208 1209 parser.add_argument('--disk_cache', dest='disk_cache', default=None, 1210 action='store', help='If specified, periodically ' 1211 'backup the in-memory cache to disk. On restart, ' 1212 'data will be reloaded from this cache.') 1213 1214 parser.add_argument('--back_seconds', dest='back_seconds', action='store', 1215 type=float, default=24 * 60 * 60, 1216 help='Maximum number of seconds of old data to keep ' 1217 'for serving to new clients.') 1218 1219 parser.add_argument('--max_records', dest='max_records', action='store', 1220 type=int, default=24 * 60 * 2, 1221 help='Maximum number of records to store per variable.') 1222 1223 parser.add_argument('--min_back_records', dest='min_back_records', action='store', 1224 type=float, default=64, 1225 help='Minimum number of back records to keep when purging old data.') 1226 1227 parser.add_argument('--cleanup_interval', dest='cleanup_interval', 1228 action='store', type=float, default=60, 1229 help='How often to clean old data out of the cache.') 1230 1231 parser.add_argument('--interval', dest='interval', action='store', 1232 type=float, default=0.5, 1233 help='How many seconds to sleep between successive ' 1234 'sends of data to clients.') 1235 1236 parser.add_argument('--stderr_file', dest='stderr_file', default=None, 1237 help='Optional file to which stderr messages should ' 1238 'be written.') 1239 1240 parser.add_argument('-v', '--verbosity', dest='verbosity', default=0, 1241 action='count', help='Increase output verbosity') 1242 args = parser.parse_args() 1243 1244 # Set logging verbosity 1245 LOG_LEVELS = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} 1246 log_level = LOG_LEVELS[min(args.verbosity, max(LOG_LEVELS))] 1247 logging.getLogger().setLevel(log_level) 1248 1249 if args.stderr_file: 1250 from logger.writers.text_file_writer import TextFileWriter # noqa: E402 1251 stderr_writer = [TextFileWriter(filename=args.stderr_file, 1252 split_by_date=True)] 1253 logging.getLogger().addHandler(StdErrLoggingHandler(stderr_writer)) 1254 1255 logging.info('Starting CachedDataServer') 1256 server = CachedDataServer(port=args.port, 1257 interval=args.interval, 1258 back_seconds=args.back_seconds, 1259 max_records=args.max_records, 1260 min_back_records=args.min_back_records, 1261 cleanup_interval=args.cleanup_interval, 1262 disk_cache=args.disk_cache) 1263 1264 # Only create reader(s) if they've given us a network to read from; 1265 # otherwise, count on data coming from websocket publish 1266 # connections. 1267 if args.udp: 1268 readers = [] 1269 # Readers may either be just a port (to listen for broadcast) or 1270 # a multicast_group:port to listen for multicast. 1271 for udp_spec in args.udp.split(','): 1272 group_port = udp_spec.split(':') 1273 port = int(group_port[-1]) 1274 multicast_group = group_port[-2] if len(group_port) == 2 else '' 1275 readers.append(UDPReader(port=port, mc_group=multicast_group)) 1276 transform = FromJSONTransform() 1277 reader = ComposedReader(readers=readers, transforms=[transform]) 1278 1279 # Loop, reading data and writing it to the cache 1280 try: 1281 while True: 1282 if args.udp: 1283 record = reader.read() 1284 server.cache_record(record) 1285 else: 1286 time.sleep(args.interval) 1287 1288 except KeyboardInterrupt: 1289 logging.warning('Received KeyboardInterrupt - shutting down') 1290 if args.disk_cache: 1291 logging.warning( 1292 'Will try to save to disk cache prior to shutdown...') 1293 server.cache.save_to_disk(args.disk_cache) 1294 server.quit()
140class RecordCache: 141 """Structure for storing/retrieving record data and metadata.""" 142 143 def __init__(self): 144 """ 145 In-memory storage for key:value pairs. 146 """ 147 self.data = {} 148 self.data_lock = threading.Lock() # When operating on whole dict 149 150 self.metadata = {} 151 self.metadata_lock = threading.Lock() 152 153 # Disk files we've tried to write to but failed. 154 self.failed_files = set() 155 156 # Create a lock for each key so threads don't step on each other 157 self.locks = {key: threading.Lock() for key in self.keys()} 158 159 ############################ 160 def cache_record(self, record): 161 """Add the passed record to the cache. 162 Expects passed records to be in one of two formats: 163 1) DASRecord 164 2) A dict encoding optionally a source data_id and timestamp and a 165 mandatory 'fields' key of field_name: value pairs. This is the format 166 emitted by default by ParseTransform: 167 ``` 168 { 169 'data_id': ..., # optional 170 'timestamp': ..., # optional - use time.time() if missing 171 'fields': { 172 field_name: value, 173 field_name: value, 174 ... 175 } 176 } 177 ``` 178 A twist on format (2) is that the values may either be a singleton 179 (int, float, string, etc) or a list. If the value is a singleton, 180 it is taken at face value. If it is a list, it is assumed to be a 181 list of (value, timestamp) tuples, in which case the top-level 182 timestamp, if any, is ignored. 183 ``` 184 { 185 'data_id': ..., # optional 186 'fields': { 187 field_name: [(timestamp, value), (timestamp, value),...], 188 field_name: [(timestamp, value), (timestamp, value),...], 189 ... 190 } 191 } 192 ``` 193 In addition to a 'fields' field, a record may contain a 'metadata' 194 field. If present, the data server will look for a 'fields' dict 195 inside the metadata dict and add the key-value pairs there to its 196 cache of metadata about the fields: 197 ``` 198 {'data_id': 's330', 199 'fields': {'S330CourseMag': 244.29, 200 'S330CourseTrue': 219.61, 201 'S330Mode': 'A', 202 'S330SpeedKm': 16.5, 203 'S330SpeedKt': 8.9}, 204 'metadata': {'fields': { 205 'S330CourseMag': {'description': 'Magnetic course', 206 'device': 's330', 207 'device_type': 'Seapath330', 208 'device_type_field': 'CourseMag', 209 'units': 'degrees'}, 210 'S330CourseTrue': {'description': 'True course', 211 ...} 212 } 213 }} 214 ``` 215 This metadata field will be generated sent at intervals by a 216 RecordParser (and its enclosing ParseTransform) if the parser's 217 ``metadata_interval`` value is not None. 218 """ 219 logging.debug('cache_record() received: %s', record) 220 if not record: 221 logging.debug('cache_record() received empty record.') 222 return 223 224 # If we've been passed a DASRecord, the field:value pairs are in a 225 # field called, uh, 'fields'; if we've been passed a dict, look 226 # for its 'fields' key. 227 if isinstance(record, DASRecord): 228 record_timestamp = record.timestamp 229 fields = record.fields 230 metadata = record.metadata 231 elif isinstance(record, dict): 232 record_timestamp = record.get('timestamp', time.time()) 233 fields = record.get('fields') 234 metadata = record.get('metadata') 235 if fields is None: 236 logging.debug( 237 'Dict record passed to cache_record() has no ' 238 '"fields" key, which either means it\'s not a dict ' 239 'you should be passing, or it is in the old "field_dict" ' 240 'format that assumes key:value pairs are at the top ' 241 'level.') 242 logging.debug('The record in question: %s', str(record)) 243 return 244 else: 245 logging.warning( 246 'Received non-DASRecord, non-dict input (type: %s): %s', 247 type(record), 248 record) 249 return 250 251 # Add values from record to cache 252 for field, value in fields.items(): 253 if field not in self.locks: 254 self.locks[field] = threading.Lock() 255 with self.locks[field]: 256 if field not in self.data: 257 self.data[field] = [] 258 259 if isinstance(value, list): 260 # Okay, for this field we have a list of values - iterate 261 # through 262 for val in value: 263 # If element in the list is itself a list or a tuple, 264 # we'll assume it's a (timestamp, value) pair. Otherwise, 265 # use the default timestamp of 'now'. 266 if type(val) in [list, tuple]: 267 self._add_tuple(field, val) 268 else: 269 self._add_tuple(field, (record_timestamp, value)) 270 else: 271 # If type(value) is *not* a list, assume it's the value 272 # itself. Add it using the default timestamp. 273 self._add_tuple(field, (record_timestamp, value)) 274 275 # Is there any metadata to add? Cache whatever is in the 276 # metadata.data.fields dict. Blithely overwrite whatever might 277 # be there already. 278 if metadata: 279 metadata_fields = metadata.get('fields', {}) 280 with self.metadata_lock: 281 for mfield, value in metadata_fields.items(): 282 self.metadata[mfield] = value 283 284 ############################ 285 def _add_tuple(self, field, value_tuple): 286 self.data[field].append(value_tuple) 287 288 ############################ 289 def keys(self): 290 """Return a list of all keys in the cache.""" 291 return list(self.data.keys()) 292 293 ############################ 294 def get_metadata(self, fields=None): 295 """Return a dict of metadata for the specified list of fields. If no 296 fields are specified, return metadata for all fields. 297 """ 298 with self.metadata_lock: 299 if fields: 300 return { 301 field: self.metadata.get( 302 field, {}) for field in fields} 303 else: 304 return self.metadata 305 306 ############################ 307 def cleanup(self, oldest=0, max_records=0, min_back_records=0): 308 """Remove any data from cache with a timestamp older than 'oldest' 309 seconds, but keep at least one (most recent) value. 310 If max_records is non-zero, truncate to that many of the most 311 recent records. Always, though, keep at least min_back_records. 312 """ 313 logging.debug('Cleaning up cache') 314 fields = self.keys() 315 for field in fields: 316 if field not in self.locks: 317 self.locks[field] = threading.Lock() 318 with self.locks[field]: 319 value_list = self.data[field] 320 321 if len(value_list) <= min_back_records: 322 continue 323 324 # If max_records is specified, truncate to keep that many of 325 # most recent records. 326 if max_records > min_back_records and len(value_list) > max_records: 327 value_list = value_list[-max_records:] 328 329 # Iterate until find value that's not too old, but leave at least 330 # min_back_records. 331 for i in range(len(value_list) - min_back_records): 332 if value_list[i][0] > oldest: 333 break 334 335 # But keep at least one value 336 last_index = min(i, len(value_list) - 1) 337 self.data[field] = value_list[last_index:] 338 339 ############################ 340 def save_to_disk(self, disk_cache): 341 """Create one JSON-encoded cache file per field in the directory named 342 by disk_cache. 343 """ 344 logging.debug('Saving to cache.') 345 if not disk_cache: 346 logging.warning('save_to_disk called, but no disk_cache defined') 347 return 348 349 if not os.path.exists(disk_cache): 350 try: 351 os.makedirs(disk_cache) 352 except OSError as e: 353 logging.error('Unable to create disk cache directory "%s": %s', disk_cache, e) 354 return 355 356 fields = self.keys() 357 for field in fields: 358 disk_filename = disk_cache + '/' + field 359 if disk_filename in self.failed_files: 360 continue 361 if field not in self.locks: 362 self.locks[field] = threading.Lock() 363 with self.locks[field]: 364 try: 365 with open(disk_filename, 'w') as cache_file: 366 json.dump(self.data[field], cache_file) 367 except (PermissionError, IOError, OSError) as e: 368 logging.warning('Unable to write disk cache file %s: %s', disk_filename, e) 369 self.failed_files.add(disk_filename) 370 371 # This is BAD practice; but use it to figure out what else might go wrong 372 # so we can add it to specific exceptions above.s 373 except Exception as e: 374 logging.warning('Unanticipated exception writing disk cache file %s: %s', 375 disk_filename, e) 376 self.failed_files.add(disk_filename) 377 378 ############################ 379 def load_from_disk(self, disk_cache): 380 """Load the data dict from directory of JSON-encoded cache files. 381 """ 382 logging.info('Loading from disk at %s', disk_cache) 383 if not disk_cache: 384 logging.info('load_from_disk called, but no disk_cache defined') 385 return 386 try: 387 if not os.path.exists(disk_cache): 388 logging.info('load_from_disk: no cache found at "%s"', disk_cache) 389 return 390 391 field_files = [f for f in os.listdir(disk_cache) 392 if os.path.isfile(os.path.join(disk_cache, f))] 393 logging.debug('Got cached fields: %s', field_files) 394 for field in field_files: 395 if field not in self.locks: 396 self.locks[field] = threading.Lock() 397 try: 398 with self.locks[field]: 399 with open(disk_cache + '/' + field, 'r') as cache_file: 400 self.data[field] = json.load(cache_file) 401 402 except (json.decoder.JSONDecodeError, UnicodeDecodeError): 403 logging.warning('Failed to parse cache for %s', field) 404 except OSError as e: 405 logging.error('Unable to access disk cache at %s: %s', disk_cache, e)
Structure for storing/retrieving record data and metadata.
143 def __init__(self): 144 """ 145 In-memory storage for key:value pairs. 146 """ 147 self.data = {} 148 self.data_lock = threading.Lock() # When operating on whole dict 149 150 self.metadata = {} 151 self.metadata_lock = threading.Lock() 152 153 # Disk files we've tried to write to but failed. 154 self.failed_files = set() 155 156 # Create a lock for each key so threads don't step on each other 157 self.locks = {key: threading.Lock() for key in self.keys()}
In-memory storage for key:value pairs.
160 def cache_record(self, record): 161 """Add the passed record to the cache. 162 Expects passed records to be in one of two formats: 163 1) DASRecord 164 2) A dict encoding optionally a source data_id and timestamp and a 165 mandatory 'fields' key of field_name: value pairs. This is the format 166 emitted by default by ParseTransform: 167 ``` 168 { 169 'data_id': ..., # optional 170 'timestamp': ..., # optional - use time.time() if missing 171 'fields': { 172 field_name: value, 173 field_name: value, 174 ... 175 } 176 } 177 ``` 178 A twist on format (2) is that the values may either be a singleton 179 (int, float, string, etc) or a list. If the value is a singleton, 180 it is taken at face value. If it is a list, it is assumed to be a 181 list of (value, timestamp) tuples, in which case the top-level 182 timestamp, if any, is ignored. 183 ``` 184 { 185 'data_id': ..., # optional 186 'fields': { 187 field_name: [(timestamp, value), (timestamp, value),...], 188 field_name: [(timestamp, value), (timestamp, value),...], 189 ... 190 } 191 } 192 ``` 193 In addition to a 'fields' field, a record may contain a 'metadata' 194 field. If present, the data server will look for a 'fields' dict 195 inside the metadata dict and add the key-value pairs there to its 196 cache of metadata about the fields: 197 ``` 198 {'data_id': 's330', 199 'fields': {'S330CourseMag': 244.29, 200 'S330CourseTrue': 219.61, 201 'S330Mode': 'A', 202 'S330SpeedKm': 16.5, 203 'S330SpeedKt': 8.9}, 204 'metadata': {'fields': { 205 'S330CourseMag': {'description': 'Magnetic course', 206 'device': 's330', 207 'device_type': 'Seapath330', 208 'device_type_field': 'CourseMag', 209 'units': 'degrees'}, 210 'S330CourseTrue': {'description': 'True course', 211 ...} 212 } 213 }} 214 ``` 215 This metadata field will be generated sent at intervals by a 216 RecordParser (and its enclosing ParseTransform) if the parser's 217 ``metadata_interval`` value is not None. 218 """ 219 logging.debug('cache_record() received: %s', record) 220 if not record: 221 logging.debug('cache_record() received empty record.') 222 return 223 224 # If we've been passed a DASRecord, the field:value pairs are in a 225 # field called, uh, 'fields'; if we've been passed a dict, look 226 # for its 'fields' key. 227 if isinstance(record, DASRecord): 228 record_timestamp = record.timestamp 229 fields = record.fields 230 metadata = record.metadata 231 elif isinstance(record, dict): 232 record_timestamp = record.get('timestamp', time.time()) 233 fields = record.get('fields') 234 metadata = record.get('metadata') 235 if fields is None: 236 logging.debug( 237 'Dict record passed to cache_record() has no ' 238 '"fields" key, which either means it\'s not a dict ' 239 'you should be passing, or it is in the old "field_dict" ' 240 'format that assumes key:value pairs are at the top ' 241 'level.') 242 logging.debug('The record in question: %s', str(record)) 243 return 244 else: 245 logging.warning( 246 'Received non-DASRecord, non-dict input (type: %s): %s', 247 type(record), 248 record) 249 return 250 251 # Add values from record to cache 252 for field, value in fields.items(): 253 if field not in self.locks: 254 self.locks[field] = threading.Lock() 255 with self.locks[field]: 256 if field not in self.data: 257 self.data[field] = [] 258 259 if isinstance(value, list): 260 # Okay, for this field we have a list of values - iterate 261 # through 262 for val in value: 263 # If element in the list is itself a list or a tuple, 264 # we'll assume it's a (timestamp, value) pair. Otherwise, 265 # use the default timestamp of 'now'. 266 if type(val) in [list, tuple]: 267 self._add_tuple(field, val) 268 else: 269 self._add_tuple(field, (record_timestamp, value)) 270 else: 271 # If type(value) is *not* a list, assume it's the value 272 # itself. Add it using the default timestamp. 273 self._add_tuple(field, (record_timestamp, value)) 274 275 # Is there any metadata to add? Cache whatever is in the 276 # metadata.data.fields dict. Blithely overwrite whatever might 277 # be there already. 278 if metadata: 279 metadata_fields = metadata.get('fields', {}) 280 with self.metadata_lock: 281 for mfield, value in metadata_fields.items(): 282 self.metadata[mfield] = value
Add the passed record to the cache. Expects passed records to be in one of two formats: 1) DASRecord 2) 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': ..., # optional
'timestamp': ..., # optional - use time.time() if missing
'fields': {
field_name: value,
field_name: value,
...
}
}
A twist on format (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': ..., # optional
'fields': {
field_name: [(timestamp, value), (timestamp, value),...],
field_name: [(timestamp, value), (timestamp, value),...],
...
}
}
In addition to a 'fields' field, a record may contain a 'metadata' field. If present, the data server will look for a 'fields' dict inside the metadata dict and add the key-value pairs there to its cache of metadata about the fields:
{'data_id': 's330',
'fields': {'S330CourseMag': 244.29,
'S330CourseTrue': 219.61,
'S330Mode': 'A',
'S330SpeedKm': 16.5,
'S330SpeedKt': 8.9},
'metadata': {'fields': {
'S330CourseMag': {'description': 'Magnetic course',
'device': 's330',
'device_type': 'Seapath330',
'device_type_field': 'CourseMag',
'units': 'degrees'},
'S330CourseTrue': {'description': 'True course',
...}
}
}}
This metadata field will be generated sent at intervals by a
RecordParser (and its enclosing ParseTransform) if the parser's
metadata_interval value is not None.
289 def keys(self): 290 """Return a list of all keys in the cache.""" 291 return list(self.data.keys())
Return a list of all keys in the cache.
294 def get_metadata(self, fields=None): 295 """Return a dict of metadata for the specified list of fields. If no 296 fields are specified, return metadata for all fields. 297 """ 298 with self.metadata_lock: 299 if fields: 300 return { 301 field: self.metadata.get( 302 field, {}) for field in fields} 303 else: 304 return self.metadata
Return a dict of metadata for the specified list of fields. If no fields are specified, return metadata for all fields.
307 def cleanup(self, oldest=0, max_records=0, min_back_records=0): 308 """Remove any data from cache with a timestamp older than 'oldest' 309 seconds, but keep at least one (most recent) value. 310 If max_records is non-zero, truncate to that many of the most 311 recent records. Always, though, keep at least min_back_records. 312 """ 313 logging.debug('Cleaning up cache') 314 fields = self.keys() 315 for field in fields: 316 if field not in self.locks: 317 self.locks[field] = threading.Lock() 318 with self.locks[field]: 319 value_list = self.data[field] 320 321 if len(value_list) <= min_back_records: 322 continue 323 324 # If max_records is specified, truncate to keep that many of 325 # most recent records. 326 if max_records > min_back_records and len(value_list) > max_records: 327 value_list = value_list[-max_records:] 328 329 # Iterate until find value that's not too old, but leave at least 330 # min_back_records. 331 for i in range(len(value_list) - min_back_records): 332 if value_list[i][0] > oldest: 333 break 334 335 # But keep at least one value 336 last_index = min(i, len(value_list) - 1) 337 self.data[field] = value_list[last_index:]
Remove any data from cache with a timestamp older than 'oldest' seconds, but keep at least one (most recent) value. If max_records is non-zero, truncate to that many of the most recent records. Always, though, keep at least min_back_records.
340 def save_to_disk(self, disk_cache): 341 """Create one JSON-encoded cache file per field in the directory named 342 by disk_cache. 343 """ 344 logging.debug('Saving to cache.') 345 if not disk_cache: 346 logging.warning('save_to_disk called, but no disk_cache defined') 347 return 348 349 if not os.path.exists(disk_cache): 350 try: 351 os.makedirs(disk_cache) 352 except OSError as e: 353 logging.error('Unable to create disk cache directory "%s": %s', disk_cache, e) 354 return 355 356 fields = self.keys() 357 for field in fields: 358 disk_filename = disk_cache + '/' + field 359 if disk_filename in self.failed_files: 360 continue 361 if field not in self.locks: 362 self.locks[field] = threading.Lock() 363 with self.locks[field]: 364 try: 365 with open(disk_filename, 'w') as cache_file: 366 json.dump(self.data[field], cache_file) 367 except (PermissionError, IOError, OSError) as e: 368 logging.warning('Unable to write disk cache file %s: %s', disk_filename, e) 369 self.failed_files.add(disk_filename) 370 371 # This is BAD practice; but use it to figure out what else might go wrong 372 # so we can add it to specific exceptions above.s 373 except Exception as e: 374 logging.warning('Unanticipated exception writing disk cache file %s: %s', 375 disk_filename, e) 376 self.failed_files.add(disk_filename)
Create one JSON-encoded cache file per field in the directory named by disk_cache.
379 def load_from_disk(self, disk_cache): 380 """Load the data dict from directory of JSON-encoded cache files. 381 """ 382 logging.info('Loading from disk at %s', disk_cache) 383 if not disk_cache: 384 logging.info('load_from_disk called, but no disk_cache defined') 385 return 386 try: 387 if not os.path.exists(disk_cache): 388 logging.info('load_from_disk: no cache found at "%s"', disk_cache) 389 return 390 391 field_files = [f for f in os.listdir(disk_cache) 392 if os.path.isfile(os.path.join(disk_cache, f))] 393 logging.debug('Got cached fields: %s', field_files) 394 for field in field_files: 395 if field not in self.locks: 396 self.locks[field] = threading.Lock() 397 try: 398 with self.locks[field]: 399 with open(disk_cache + '/' + field, 'r') as cache_file: 400 self.data[field] = json.load(cache_file) 401 402 except (json.decoder.JSONDecodeError, UnicodeDecodeError): 403 logging.warning('Failed to parse cache for %s', field) 404 except OSError as e: 405 logging.error('Unable to access disk cache at %s: %s', disk_cache, e)
Load the data dict from directory of JSON-encoded cache files.
409class WebSocketConnection: 410 """Handle the websocket connection, serving data as requested.""" 411 ############################ 412 413 def __init__(self, websocket, cache, interval): 414 self.websocket = websocket 415 self.cache = cache 416 self.interval = interval 417 self.quit_flag = False 418 419 ############################ 420 def closed(self): 421 """Has our client closed the connection?""" 422 return self.quit_flag 423 424 ############################ 425 def quit(self): 426 """Close the connection from our end and quit.""" 427 self.quit_flag = True 428 429 ############################ 430 def get_matching_field_names(self, field_name): 431 """If a wildcard field is present, returns a list 432 (matching_field_names) of all the fields that match the 433 pattern. Otherwise, it just returns the field_name as the sole 434 entry in the list. 435 436 field_name - the name of the field as specified in the subscription request 437 """ 438 439 matching_field_names = set() 440 441 # If the field name is a wildcard 442 if '*' in field_name: 443 field_name = field_name.replace("*", ".+") 444 445 for field in self.cache.keys(): 446 if re.search(field_name, field): 447 matching_field_names.add(field) 448 449 # If here, the field name is not a wildcard 450 else: 451 matching_field_names.add(field_name) 452 453 return list(matching_field_names) 454 455 ############################ 456 457 async def send_json_response(self, response, is_error=False): 458 logging.debug('CachedDataServer sending %d bytes', 459 len(json.dumps(response))) 460 await self.websocket.send(json.dumps(response)) 461 if is_error: 462 logging.warning(response) 463 464 ############################ 465 async def serve_requests(self): 466 """Wait for requests and serve data, if it exists, from 467 cache. Requests are in JSON with request type encoded in 468 'request_type' field. Recognized request types are: 469 ``` 470 fields - return a (JSON encoded) list of fields for which cache 471 has data. 472 describe - return a (JSON encoded) dict of metadata for the listed 473 fields. 474 publish - look for a field called 'data' and expect its value to 475 be a dict containing data in one of the formats accepted by 476 cache_record(). 477 subscribe - look for a field called 'fields' in the request whose 478 value is a dict of the format 479 ``` 480 {field_name:{seconds:600, back_records:10}, 481 field_name:{seconds:0},...} 482 ``` 483 The entire specification may also have a field called 484 'interval', specifying how often server should provide 485 updates. Will default to what was specified on command line 486 with --interval flag (which itself defaults to 1 second 487 intervals). 488 ``` 489 ``` 490 A subscription will instruct the CachedDataServer to begin 491 serving JSON messages of the format 492 ``` 493 { 494 field_name: [(timestamp, value), (timestamp, value),...], 495 field_name: [(timestamp, value), (timestamp, value),...], 496 field_name: [(timestamp, value), (timestamp, value),...], 497 } 498 ``` 499 Initially provide the number of seconds worth of back data 500 requested, and on subsequent calls, return all data that have 501 arrived since last call. 502 NOTE: if the 'seconds' field is -1, server will only ever provide 503 the single most recent value for the relevant field. 504 ready - client has processed the previous data message and is ready 505 for more. 506 ``` 507 """ 508 # The field details specified in a subscribe request 509 requested_fields = {} 510 511 # A map from field_name:latest_timestamp_sent. If latest_timestamp_sent is -1 512 # then we'll always send just the most recent value we have for the field, 513 # regardless of how many there are, or whether we've sent it before. 514 field_timestamps = {} 515 516 # Output format requested by the client; set by a subscribe message. 517 # Default matches the subscribe handler's own default so a 'ready' 518 # received before any 'subscribe' degrades gracefully. 519 requested_format = 'field_dict' 520 521 interval = self.interval # Use the default interval, uh, by default 522 523 while not self.quit_flag: 524 now = time.time() 525 try: 526 logging.debug('Waiting for client') 527 raw_request = await self.websocket.recv() 528 request = json.loads(raw_request) 529 530 # Make sure we've received a dict 531 if not isinstance(request, dict): 532 await self.send_json_response( 533 {'status': 400, 'error': 'non-dict request received'}, 534 is_error=True) 535 536 # Make sure request dict has a 'type' field 537 elif 'type' not in request: 538 await self.send_json_response( 539 {'status': 400, 'error': 'no "type" field found in request'}, 540 is_error=True) 541 542 # Let's see what type of request it is 543 544 # Send client a list of the variable names we're able to serve. 545 elif request['type'] == 'fields': 546 logging.debug('fields request') 547 await self.send_json_response( 548 {'type': 'fields', 'status': 200, 549 'data': self.cache.keys()}) 550 551 # Send client a dict of metadata descriptions; if they've 552 # specified a set of fields, give just metadata for those; 553 # otherwise send everything. 554 elif request['type'] == 'describe': 555 logging.debug('describe request') 556 fields = request.get('fields') 557 result = self.cache.get_metadata(fields) 558 await self.send_json_response( 559 {'type': 'describe', 'status': 200, 'data': result}) 560 561 # Client wants to publish to cache and provides a dict of data 562 elif request['type'] == 'publish': 563 logging.debug('publish request') 564 data = request.get('data') 565 if data is None: 566 await self.send_json_response( 567 {'type': 'publish', 'status': 400, 568 'error': 'no data field found in request'}, 569 is_error=True) 570 elif not isinstance(data, dict): 571 await self.send_json_response( 572 {'type': 'publish', 'status': 400, 573 'error': 'request has non-dict data field'}, 574 is_error=True) 575 else: 576 self.cache.cache_record(data) 577 await self.send_json_response({'type': 'publish', 'status': 200}) 578 579 # Client wants to subscribe, and provides a dict of requested 580 # fields 581 elif request['type'] == 'subscribe': 582 logging.debug('subscribe request') 583 # Have they given us a new subscription interval? 584 requested_interval = request.get('interval') 585 if requested_interval is not None: 586 try: 587 interval = float(requested_interval) 588 except ValueError: 589 await self.send_json_response( 590 {'type': 'subscribe', 'status': 400, 591 'error': 'non-numeric interval requested'}, 592 is_error=True) 593 continue 594 595 # Which fields do they want? 596 raw_requested_fields = request.get('fields') 597 if not raw_requested_fields: 598 await self.send_json_response( 599 {'type': 'subscribe', 'status': 400, 600 'error': 'no fields found in subscribe request'}, 601 is_error=True) 602 continue 603 604 # What format do they want output in? field_dict? 605 # record_list? By default, use field_dict. 606 requested_format = request.get('format', 'field_dict') 607 608 # Parse out request field names and number of back seconds 609 # requested. Encode that as 'last timestamp sent', unless back 610 # seconds == -1. If -1, save it as -1, so that we know we're 611 # always just sending the the most recent field value. Stores 612 # all fields, including expanded entries from a wildcard, in the 613 # requested_fields dict. 614 615 now = time.time() 616 617 # Reset requested field_timestamps and field_back_records 618 requested_fields = {} 619 field_timestamps = {} # last timestamp seen 620 621 logging.debug('Subscription requested') 622 for field_name, field_spec in raw_requested_fields.items(): 623 matching_field_names = self.get_matching_field_names(field_name) 624 625 for matching_field_name in matching_field_names: 626 requested_fields[matching_field_name] = field_spec 627 # If we don't have a field spec dict 628 if isinstance(field_spec, dict): 629 back_records = field_spec.get('back_records', 0) 630 back_seconds = field_spec.get('seconds', 0) 631 else: 632 back_records = 0 633 back_seconds = 0 634 635 # Now figure out what's the latest timestamp we have for this 636 # field name that respects the back_records and back_seconds 637 # specification. 638 field_timestamps[matching_field_name] = 0 # if nothing else 639 640 if field_name not in self.cache.locks: 641 logging.debug('No data for requested field %s', matching_field_name) 642 continue 643 with self.cache.locks[field_name]: 644 field_cache = self.cache.data.get(matching_field_name) 645 if field_cache is None: 646 logging.debug('No cached data for %s', matching_field_name) 647 continue 648 649 logging.debug(' %s: %d records available; %d requested, ' 650 '%d seconds', matching_field_name, len(field_cache), 651 back_records, back_seconds) 652 # If no data for requested field, skip. 653 if not field_cache or not field_cache[-1]: 654 continue 655 656 # If special case 0, they only want records that come after 657 # this point in time. Set the last timestamp seen as the 658 # most-recently seen timestamp, or zero if no entries. 659 if back_seconds == 0: 660 if len(field_cache) > 0: 661 field_timestamps[matching_field_name] = field_cache[-1][0] 662 else: 663 field_timestamps[matching_field_name] = 0 664 continue 665 666 # If special case -1, they want just single most recent 667 # value. Set the last timestamp seen as the second to last 668 # timestamp if multiple entries, or as zero, if only 1. 669 if back_seconds == -1: 670 if len(field_cache) > 1: 671 field_timestamps[matching_field_name] = field_cache[-2][0] 672 else: 673 field_timestamps[matching_field_name] = 0 674 continue 675 676 # We've been told to return at least 'back_records' records; if 677 # there aren't at least that many, leave field_timestamps[field] 678 # at zero to return all we've got. 679 if len(field_cache) <= back_records: 680 continue 681 682 # If here, we've got at least 'back_records' records, and want to 683 # search backward to include the last 'back_seconds' seconds of 684 # them. Could do more efficiently with some sort of binary search. 685 this_record_index = len(field_cache) - back_records - 1 686 while this_record_index >= 0: 687 # Recall that each element is (timestamp, value) 688 this_timestamp = field_cache[this_record_index][0] 689 690 if now - this_timestamp > back_seconds: 691 # Set our 'last seen' timestamp as timestamp of previous 692 # record and stop looking. 693 prev_timestamp = field_cache[this_record_index-1][0] 694 field_timestamps[matching_field_name] = prev_timestamp 695 break 696 this_record_index -= 1 697 698 if raw_requested_fields and not requested_fields: 699 logging.info('Request doesn\'t match any existing fields') 700 701 # Let client know request succeeded 702 await self.send_json_response({'type': 'subscribe', 'status': 200}) 703 704 # Client just letting us know it's ready for more. If there are 705 # fields that have been requested, send along any new data for 706 # them. 707 elif request['type'] == 'ready': 708 logging.debug('Websocket got ready...') 709 if not field_timestamps: 710 # Client has told us that they're ready, but there are no 711 # fields that match their request. Let them know, then 712 # pause a moment before we try fielding their next 713 # request. 714 await self.send_json_response( 715 {'type': 'ready', 'status': 400, 716 'error': 'client ready, but no matching fields found (yet).'}, 717 is_error=False) 718 await asyncio.sleep(self.interval * 5) 719 720 ########## 721 results = {} 722 if requested_format == 'field_dict': 723 for field_name, field_spec in requested_fields.items(): 724 if field_name not in self.cache.locks: 725 logging.debug('No data for requested field %s', field_name) 726 continue 727 728 with self.cache.locks[field_name]: 729 field_cache = self.cache.data.get(field_name) 730 if field_cache is None: 731 logging.debug( 732 'No cached data for %s', field_name) 733 continue 734 735 # If no data for requested field, skip. 736 if not field_cache or not field_cache[-1]: 737 continue 738 739 # If special case -1, they want just single most recent 740 # value, then future results. Grab last value, then set its 741 # timestamp as the last one we've seen. 742 back_seconds = field_spec.get('back_seconds', 0) 743 if back_seconds == -1: 744 last_value = field_cache[-1] 745 results[field_name] = [last_value] 746 # ts of last value 747 field_timestamps[field_name] = last_value[0] 748 continue 749 750 # Otherwise - if no data newer than the latest 751 # timestamp we've already sent, skip, 752 latest_timestamp = field_timestamps.get(field_name, 0) 753 if not field_cache[-1][0] > latest_timestamp: 754 continue 755 756 # Otherwise, copy over records arrived since 757 # latest_timestamp and update the latest_timestamp sent 758 # (first element of last pair in field_cache). 759 field_results = [ 760 pair for pair in field_cache if pair[0] > latest_timestamp] 761 results[field_name] = field_results 762 if field_results: 763 field_timestamps[field_name] = field_results[-1][0] 764 765 ########## 766 # If not outputting data as a field dict, output as a list 767 # of records. 768 elif requested_format == 'record_list': 769 records = {} 770 for field_name, field_spec in requested_fields.items(): 771 if field_name not in self.cache.locks: 772 logging.debug( 773 'No data for requested field %s', field_name) 774 continue 775 with self.cache.locks[field_name]: 776 latest_timestamp = field_timestamps.get( 777 field_name, 0) 778 field_cache = self.cache.data.get( 779 field_name, None) 780 781 if not field_cache or not field_cache[-1]: 782 logging.debug( 783 'No cached data for %s', field_name) 784 continue 785 786 # If latest_timestamp is special case -1, they want just 787 # single most recent value, then future results. Grab 788 # last value, then set its timestamp as the last one 789 # we've seen. 790 elif latest_timestamp == -1: 791 last_ts, last_value = field_cache[-1] 792 if last_ts not in records: 793 records[last_ts] = {} 794 records[last_ts][field_name] = last_value 795 field_timestamps[field_name] = last_ts 796 continue 797 798 # Otherwise - if no data newer than the latest 799 # timestamp we've already sent, skip, 800 elif not field_cache[-1][0] > latest_timestamp: 801 continue 802 803 # Otherwise, copy over records arrived since 804 # latest_timestamp and update the latest_timestamp sent 805 # (first element of last pair in field_cache). 806 else: 807 # Get the new (ts, value) pairs for this 808 # field 809 field_results = [ 810 pair for pair in field_cache if pair[0] > latest_timestamp] 811 812 # We know field_results is non-empty because of previous 813 # elif, so new latest timestamp is last ts 814 # in it. 815 field_timestamps[field_name] = field_results[-1][0] 816 817 # Collate values by timestamp, folding into values for 818 # other fields. 819 for ts, value in field_results: 820 if ts not in records: 821 records[ts] = {} 822 records[ts][field_name] = value 823 824 # Create and send a list with one DASRecord-like dict for 825 # each timestamp. 826 results = [{'timestamp': ts, 'fields': records[ts]} 827 for ts in sorted(records)] 828 829 # If unknown requested format 830 else: 831 mesg = ( 832 'Unrecognized requested format: %s; valid formats are ' 833 '"field_dict" and "record_list"' % 834 requested_format) 835 logging.warning(mesg) 836 await self.send_json_response({'status': 400, 'error': mesg}, is_error=True) 837 838 logging.debug( 839 'Websocket results: %s...', 840 str(results)[ 841 0:100]) 842 843 # Package up what results we have (if any) and send them 844 # off 845 await self.send_json_response({'type': 'data', 'status': 200, 846 'data': results}) 847 848 # New results or not, take a nap before trying to fetch 849 # more results 850 elapsed = time.time() - now 851 time_to_sleep = max(0, interval - elapsed) 852 logging.debug('Sleeping %g seconds', time_to_sleep) 853 await asyncio.sleep(time_to_sleep) 854 855 # If unrecognized request type - whine, then iterate 856 else: 857 await self.send_json_response( 858 {'status': 400, 859 'error': 'unrecognized request type: %s' % request['type']}, 860 is_error=True) 861 862 # If we got bad input, complain and loop 863 except json.JSONDecodeError: 864 await self.send_json_response( 865 {'status': 400, 'error': 'received unparseable JSON'}, 866 is_error=True) 867 logging.warning('unparseable JSON: %s', raw_request) 868 869 # If our connection closed, complain and exit gracefully 870 except ConnectionClosed: 871 logging.info('Client closed connection') 872 self.quit()
Handle the websocket connection, serving data as requested.
430 def get_matching_field_names(self, field_name): 431 """If a wildcard field is present, returns a list 432 (matching_field_names) of all the fields that match the 433 pattern. Otherwise, it just returns the field_name as the sole 434 entry in the list. 435 436 field_name - the name of the field as specified in the subscription request 437 """ 438 439 matching_field_names = set() 440 441 # If the field name is a wildcard 442 if '*' in field_name: 443 field_name = field_name.replace("*", ".+") 444 445 for field in self.cache.keys(): 446 if re.search(field_name, field): 447 matching_field_names.add(field) 448 449 # If here, the field name is not a wildcard 450 else: 451 matching_field_names.add(field_name) 452 453 return list(matching_field_names)
If a wildcard field is present, returns a list (matching_field_names) of all the fields that match the pattern. Otherwise, it just returns the field_name as the sole entry in the list.
field_name - the name of the field as specified in the subscription request
465 async def serve_requests(self): 466 """Wait for requests and serve data, if it exists, from 467 cache. Requests are in JSON with request type encoded in 468 'request_type' field. Recognized request types are: 469 ``` 470 fields - return a (JSON encoded) list of fields for which cache 471 has data. 472 describe - return a (JSON encoded) dict of metadata for the listed 473 fields. 474 publish - look for a field called 'data' and expect its value to 475 be a dict containing data in one of the formats accepted by 476 cache_record(). 477 subscribe - look for a field called 'fields' in the request whose 478 value is a dict of the format 479 ``` 480 {field_name:{seconds:600, back_records:10}, 481 field_name:{seconds:0},...} 482 ``` 483 The entire specification may also have a field called 484 'interval', specifying how often server should provide 485 updates. Will default to what was specified on command line 486 with --interval flag (which itself defaults to 1 second 487 intervals). 488 ``` 489 ``` 490 A subscription will instruct the CachedDataServer to begin 491 serving JSON messages of the format 492 ``` 493 { 494 field_name: [(timestamp, value), (timestamp, value),...], 495 field_name: [(timestamp, value), (timestamp, value),...], 496 field_name: [(timestamp, value), (timestamp, value),...], 497 } 498 ``` 499 Initially provide the number of seconds worth of back data 500 requested, and on subsequent calls, return all data that have 501 arrived since last call. 502 NOTE: if the 'seconds' field is -1, server will only ever provide 503 the single most recent value for the relevant field. 504 ready - client has processed the previous data message and is ready 505 for more. 506 ``` 507 """ 508 # The field details specified in a subscribe request 509 requested_fields = {} 510 511 # A map from field_name:latest_timestamp_sent. If latest_timestamp_sent is -1 512 # then we'll always send just the most recent value we have for the field, 513 # regardless of how many there are, or whether we've sent it before. 514 field_timestamps = {} 515 516 # Output format requested by the client; set by a subscribe message. 517 # Default matches the subscribe handler's own default so a 'ready' 518 # received before any 'subscribe' degrades gracefully. 519 requested_format = 'field_dict' 520 521 interval = self.interval # Use the default interval, uh, by default 522 523 while not self.quit_flag: 524 now = time.time() 525 try: 526 logging.debug('Waiting for client') 527 raw_request = await self.websocket.recv() 528 request = json.loads(raw_request) 529 530 # Make sure we've received a dict 531 if not isinstance(request, dict): 532 await self.send_json_response( 533 {'status': 400, 'error': 'non-dict request received'}, 534 is_error=True) 535 536 # Make sure request dict has a 'type' field 537 elif 'type' not in request: 538 await self.send_json_response( 539 {'status': 400, 'error': 'no "type" field found in request'}, 540 is_error=True) 541 542 # Let's see what type of request it is 543 544 # Send client a list of the variable names we're able to serve. 545 elif request['type'] == 'fields': 546 logging.debug('fields request') 547 await self.send_json_response( 548 {'type': 'fields', 'status': 200, 549 'data': self.cache.keys()}) 550 551 # Send client a dict of metadata descriptions; if they've 552 # specified a set of fields, give just metadata for those; 553 # otherwise send everything. 554 elif request['type'] == 'describe': 555 logging.debug('describe request') 556 fields = request.get('fields') 557 result = self.cache.get_metadata(fields) 558 await self.send_json_response( 559 {'type': 'describe', 'status': 200, 'data': result}) 560 561 # Client wants to publish to cache and provides a dict of data 562 elif request['type'] == 'publish': 563 logging.debug('publish request') 564 data = request.get('data') 565 if data is None: 566 await self.send_json_response( 567 {'type': 'publish', 'status': 400, 568 'error': 'no data field found in request'}, 569 is_error=True) 570 elif not isinstance(data, dict): 571 await self.send_json_response( 572 {'type': 'publish', 'status': 400, 573 'error': 'request has non-dict data field'}, 574 is_error=True) 575 else: 576 self.cache.cache_record(data) 577 await self.send_json_response({'type': 'publish', 'status': 200}) 578 579 # Client wants to subscribe, and provides a dict of requested 580 # fields 581 elif request['type'] == 'subscribe': 582 logging.debug('subscribe request') 583 # Have they given us a new subscription interval? 584 requested_interval = request.get('interval') 585 if requested_interval is not None: 586 try: 587 interval = float(requested_interval) 588 except ValueError: 589 await self.send_json_response( 590 {'type': 'subscribe', 'status': 400, 591 'error': 'non-numeric interval requested'}, 592 is_error=True) 593 continue 594 595 # Which fields do they want? 596 raw_requested_fields = request.get('fields') 597 if not raw_requested_fields: 598 await self.send_json_response( 599 {'type': 'subscribe', 'status': 400, 600 'error': 'no fields found in subscribe request'}, 601 is_error=True) 602 continue 603 604 # What format do they want output in? field_dict? 605 # record_list? By default, use field_dict. 606 requested_format = request.get('format', 'field_dict') 607 608 # Parse out request field names and number of back seconds 609 # requested. Encode that as 'last timestamp sent', unless back 610 # seconds == -1. If -1, save it as -1, so that we know we're 611 # always just sending the the most recent field value. Stores 612 # all fields, including expanded entries from a wildcard, in the 613 # requested_fields dict. 614 615 now = time.time() 616 617 # Reset requested field_timestamps and field_back_records 618 requested_fields = {} 619 field_timestamps = {} # last timestamp seen 620 621 logging.debug('Subscription requested') 622 for field_name, field_spec in raw_requested_fields.items(): 623 matching_field_names = self.get_matching_field_names(field_name) 624 625 for matching_field_name in matching_field_names: 626 requested_fields[matching_field_name] = field_spec 627 # If we don't have a field spec dict 628 if isinstance(field_spec, dict): 629 back_records = field_spec.get('back_records', 0) 630 back_seconds = field_spec.get('seconds', 0) 631 else: 632 back_records = 0 633 back_seconds = 0 634 635 # Now figure out what's the latest timestamp we have for this 636 # field name that respects the back_records and back_seconds 637 # specification. 638 field_timestamps[matching_field_name] = 0 # if nothing else 639 640 if field_name not in self.cache.locks: 641 logging.debug('No data for requested field %s', matching_field_name) 642 continue 643 with self.cache.locks[field_name]: 644 field_cache = self.cache.data.get(matching_field_name) 645 if field_cache is None: 646 logging.debug('No cached data for %s', matching_field_name) 647 continue 648 649 logging.debug(' %s: %d records available; %d requested, ' 650 '%d seconds', matching_field_name, len(field_cache), 651 back_records, back_seconds) 652 # If no data for requested field, skip. 653 if not field_cache or not field_cache[-1]: 654 continue 655 656 # If special case 0, they only want records that come after 657 # this point in time. Set the last timestamp seen as the 658 # most-recently seen timestamp, or zero if no entries. 659 if back_seconds == 0: 660 if len(field_cache) > 0: 661 field_timestamps[matching_field_name] = field_cache[-1][0] 662 else: 663 field_timestamps[matching_field_name] = 0 664 continue 665 666 # If special case -1, they want just single most recent 667 # value. Set the last timestamp seen as the second to last 668 # timestamp if multiple entries, or as zero, if only 1. 669 if back_seconds == -1: 670 if len(field_cache) > 1: 671 field_timestamps[matching_field_name] = field_cache[-2][0] 672 else: 673 field_timestamps[matching_field_name] = 0 674 continue 675 676 # We've been told to return at least 'back_records' records; if 677 # there aren't at least that many, leave field_timestamps[field] 678 # at zero to return all we've got. 679 if len(field_cache) <= back_records: 680 continue 681 682 # If here, we've got at least 'back_records' records, and want to 683 # search backward to include the last 'back_seconds' seconds of 684 # them. Could do more efficiently with some sort of binary search. 685 this_record_index = len(field_cache) - back_records - 1 686 while this_record_index >= 0: 687 # Recall that each element is (timestamp, value) 688 this_timestamp = field_cache[this_record_index][0] 689 690 if now - this_timestamp > back_seconds: 691 # Set our 'last seen' timestamp as timestamp of previous 692 # record and stop looking. 693 prev_timestamp = field_cache[this_record_index-1][0] 694 field_timestamps[matching_field_name] = prev_timestamp 695 break 696 this_record_index -= 1 697 698 if raw_requested_fields and not requested_fields: 699 logging.info('Request doesn\'t match any existing fields') 700 701 # Let client know request succeeded 702 await self.send_json_response({'type': 'subscribe', 'status': 200}) 703 704 # Client just letting us know it's ready for more. If there are 705 # fields that have been requested, send along any new data for 706 # them. 707 elif request['type'] == 'ready': 708 logging.debug('Websocket got ready...') 709 if not field_timestamps: 710 # Client has told us that they're ready, but there are no 711 # fields that match their request. Let them know, then 712 # pause a moment before we try fielding their next 713 # request. 714 await self.send_json_response( 715 {'type': 'ready', 'status': 400, 716 'error': 'client ready, but no matching fields found (yet).'}, 717 is_error=False) 718 await asyncio.sleep(self.interval * 5) 719 720 ########## 721 results = {} 722 if requested_format == 'field_dict': 723 for field_name, field_spec in requested_fields.items(): 724 if field_name not in self.cache.locks: 725 logging.debug('No data for requested field %s', field_name) 726 continue 727 728 with self.cache.locks[field_name]: 729 field_cache = self.cache.data.get(field_name) 730 if field_cache is None: 731 logging.debug( 732 'No cached data for %s', field_name) 733 continue 734 735 # If no data for requested field, skip. 736 if not field_cache or not field_cache[-1]: 737 continue 738 739 # If special case -1, they want just single most recent 740 # value, then future results. Grab last value, then set its 741 # timestamp as the last one we've seen. 742 back_seconds = field_spec.get('back_seconds', 0) 743 if back_seconds == -1: 744 last_value = field_cache[-1] 745 results[field_name] = [last_value] 746 # ts of last value 747 field_timestamps[field_name] = last_value[0] 748 continue 749 750 # Otherwise - if no data newer than the latest 751 # timestamp we've already sent, skip, 752 latest_timestamp = field_timestamps.get(field_name, 0) 753 if not field_cache[-1][0] > latest_timestamp: 754 continue 755 756 # Otherwise, copy over records arrived since 757 # latest_timestamp and update the latest_timestamp sent 758 # (first element of last pair in field_cache). 759 field_results = [ 760 pair for pair in field_cache if pair[0] > latest_timestamp] 761 results[field_name] = field_results 762 if field_results: 763 field_timestamps[field_name] = field_results[-1][0] 764 765 ########## 766 # If not outputting data as a field dict, output as a list 767 # of records. 768 elif requested_format == 'record_list': 769 records = {} 770 for field_name, field_spec in requested_fields.items(): 771 if field_name not in self.cache.locks: 772 logging.debug( 773 'No data for requested field %s', field_name) 774 continue 775 with self.cache.locks[field_name]: 776 latest_timestamp = field_timestamps.get( 777 field_name, 0) 778 field_cache = self.cache.data.get( 779 field_name, None) 780 781 if not field_cache or not field_cache[-1]: 782 logging.debug( 783 'No cached data for %s', field_name) 784 continue 785 786 # If latest_timestamp is special case -1, they want just 787 # single most recent value, then future results. Grab 788 # last value, then set its timestamp as the last one 789 # we've seen. 790 elif latest_timestamp == -1: 791 last_ts, last_value = field_cache[-1] 792 if last_ts not in records: 793 records[last_ts] = {} 794 records[last_ts][field_name] = last_value 795 field_timestamps[field_name] = last_ts 796 continue 797 798 # Otherwise - if no data newer than the latest 799 # timestamp we've already sent, skip, 800 elif not field_cache[-1][0] > latest_timestamp: 801 continue 802 803 # Otherwise, copy over records arrived since 804 # latest_timestamp and update the latest_timestamp sent 805 # (first element of last pair in field_cache). 806 else: 807 # Get the new (ts, value) pairs for this 808 # field 809 field_results = [ 810 pair for pair in field_cache if pair[0] > latest_timestamp] 811 812 # We know field_results is non-empty because of previous 813 # elif, so new latest timestamp is last ts 814 # in it. 815 field_timestamps[field_name] = field_results[-1][0] 816 817 # Collate values by timestamp, folding into values for 818 # other fields. 819 for ts, value in field_results: 820 if ts not in records: 821 records[ts] = {} 822 records[ts][field_name] = value 823 824 # Create and send a list with one DASRecord-like dict for 825 # each timestamp. 826 results = [{'timestamp': ts, 'fields': records[ts]} 827 for ts in sorted(records)] 828 829 # If unknown requested format 830 else: 831 mesg = ( 832 'Unrecognized requested format: %s; valid formats are ' 833 '"field_dict" and "record_list"' % 834 requested_format) 835 logging.warning(mesg) 836 await self.send_json_response({'status': 400, 'error': mesg}, is_error=True) 837 838 logging.debug( 839 'Websocket results: %s...', 840 str(results)[ 841 0:100]) 842 843 # Package up what results we have (if any) and send them 844 # off 845 await self.send_json_response({'type': 'data', 'status': 200, 846 'data': results}) 847 848 # New results or not, take a nap before trying to fetch 849 # more results 850 elapsed = time.time() - now 851 time_to_sleep = max(0, interval - elapsed) 852 logging.debug('Sleeping %g seconds', time_to_sleep) 853 await asyncio.sleep(time_to_sleep) 854 855 # If unrecognized request type - whine, then iterate 856 else: 857 await self.send_json_response( 858 {'status': 400, 859 'error': 'unrecognized request type: %s' % request['type']}, 860 is_error=True) 861 862 # If we got bad input, complain and loop 863 except json.JSONDecodeError: 864 await self.send_json_response( 865 {'status': 400, 'error': 'received unparseable JSON'}, 866 is_error=True) 867 logging.warning('unparseable JSON: %s', raw_request) 868 869 # If our connection closed, complain and exit gracefully 870 except ConnectionClosed: 871 logging.info('Client closed connection') 872 self.quit()
Wait for requests and serve data, if it exists, from cache. Requests are in JSON with request type encoded in 'request_type' field. Recognized request types are:
fields - return a (JSON encoded) list of fields for which cache
has data.
describe - return a (JSON encoded) dict of metadata for the listed
fields.
publish - look for a field called 'data' and expect its value to
be a dict containing data in one of the formats accepted by
cache_record().
subscribe - look for a field called 'fields' in the request whose
value is a dict of the format
{field_name:{seconds:600, back_records:10},
field_name:{seconds:0},...}
The entire specification may also have a field called
'interval', specifying how often server should provide
updates. Will default to what was specified on command line
with --interval flag (which itself defaults to 1 second
intervals).
A subscription will instruct the CachedDataServer to begin
serving JSON messages of the format
{
field_name: [(timestamp, value), (timestamp, value),...],
field_name: [(timestamp, value), (timestamp, value),...],
field_name: [(timestamp, value), (timestamp, value),...],
}
```
Initially provide the number of seconds worth of back data
requested, and on subsequent calls, return all data that have
arrived since last call.
NOTE: if the 'seconds' field is -1, server will only ever provide
the single most recent value for the relevant field.
ready - client has processed the previous data message and is ready for more. ```
876class CachedDataServer: 877 """Class that caches field:value pairs passed to it in either a 878 DASRecord or a simple dict. It also establishes a websocket server 879 on the specified port and serves the cached values to clients that 880 connect via a websocket. 881 882 WebSocket API: 883 884 The server listens for two types of requests: 885 1. If the request is the string "variables", return a list of the 886 names of the variables the server has in cache and is able to 887 serve. The server will continue listening for follow up messages, 888 most likely this one: 889 2. If the request is a python dict, assume it is of the form: 890 ``` 891 {field_1_name: {'seconds': num_secs}, 892 field_2_name: {'seconds': num_secs}, 893 ...} 894 ``` 895 where seconds is a float representing the number of seconds of 896 back data being requested. 897 This field dict is passed to serve_fields(), which will to retrieve 898 num_secs of back data for each of the specified fields and return it 899 as a JSON-encoded dict of the form: 900 ``` 901 { 902 field_1_name: [(timestamp, value), (timestamp, value), ...], 903 field_2_name: [(timestamp, value), (timestamp, value), ...], 904 ... 905 } 906 ``` 907 The server will then await a "ready" message from the client, and when 908 received, will loop and send a JSON-encoded dict of all the 909 (timestamp, value) tuples that have come in since the previous 910 request. It will continue this behavior indefinitely, waiting for a 911 "ready" request and sending updates. 912 913 HTTP GET API (requires websockets >= 12): 914 915 Plain HTTP GET requests are also accepted on the same port, allowing 916 simple tools to retrieve the latest cached value for one or more 917 fields without a WebSocket handshake: 918 919 GET /fields 920 Returns {"fields": ["field_1", "field_2", ...]} 921 922 GET /latest/<field_1>[,<field_2>,...] 923 Returns {"field_1": {"timestamp": T, "value": V}, ...} 924 Fields not present in the cache are returned as null. 925 926 CAUTION: HTTP GET requests are handled inside the same asyncio event 927 loop that drives all WebSocket connections. Each request briefly 928 acquires the cache's threading lock, blocking the loop for the 929 duration of the lookup. For occasional one-off queries (e.g. populating 930 an elog entry) this is negligible. High-frequency polling from scripts 931 or automated tools will delay WebSocket data delivery to all connected 932 clients. Use the WebSocket subscription API for any use case that 933 requires frequent or continuous updates. 934 """ 935 936 ############################ 937 def __init__( 938 self, 939 port, 940 interval=1, 941 back_seconds=60 * 60, 942 max_records=60 * 24, 943 min_back_records=100, 944 cleanup_interval=60, 945 disk_cache=None): 946 """ 947 port Port on which to serve websocket connections 948 interval How frequently to serve updates 949 back_seconds 950 How many seconds of back data to retain 951 max_records 952 Maximum number of records to store for each variable 953 min_back_records 954 Minimum number of back records to keep when purging old data 955 cleanup_interval 956 How many seconds between calls to cleanup old cache entries 957 and save to disk (if disk_cache is specified) 958 disk_cache If not None, name of directory in which to backup values 959 from in-memory cache 960 """ 961 self.port = port 962 self.interval = interval 963 self.back_seconds = back_seconds 964 self.max_records = max_records 965 self.min_back_records = min_back_records 966 self.cleanup_interval = cleanup_interval 967 968 self.cache = RecordCache() 969 970 # If they've given us the name of a disk cache, try loading our 971 # RecordCache from it. 972 self.disk_cache = disk_cache 973 if disk_cache: 974 self.cache.load_from_disk(disk_cache) 975 976 # List where we'll store our websocket connections so that we can 977 # keep track of which are still open, and signal them to close 978 # when we're done. 979 self._connections = [] 980 self._connection_lock = threading.Lock() 981 982 self.quit_flag = False 983 984 # Start a thread to loop through, cleaning up the cache and (if we've 985 # been given a disk_cache file) backing memory up to it. 986 threading.Thread(target=self.cleanup_loop, daemon=True).start() 987 988 # Fire up the thread that's going to the websocket server in our 989 # event loop. Calling quit() it will close any remaining 990 # connections and stop the event loop, terminating the server. 991 self.server_thread = threading.Thread( 992 target=self._start_event_loop, daemon=True) 993 self.server_thread.start() 994 995 def _start_event_loop(self): 996 """Initialize and run the asyncio event loop in the current thread.""" 997 try: 998 self.event_loop = asyncio.new_event_loop() 999 asyncio.set_event_loop(self.event_loop) # Bind the loop to this thread 1000 self._run_websocket_server() 1001 except Exception as e: 1002 logging.error('Failed to start event loop: %s', str(e)) 1003 1004 ############################ 1005 def __del__(self): 1006 if self.event_loop: 1007 self.event_loop.stop() 1008 self.event_loop.close() 1009 1010 ############################ 1011 def cache_record(self, record): 1012 """Cache the passed record.""" 1013 self.cache.cache_record(record) 1014 1015 ############################ 1016 def cleanup_loop(self): 1017 """Clear out records older than oldest seconds.""" 1018 while not self.quit_flag: 1019 time.sleep(self.cleanup_interval) 1020 1021 # What's the oldest record we should retain? 1022 oldest = time.time() - self.back_seconds 1023 self.cache.cleanup(oldest=oldest, max_records=self.max_records, 1024 min_back_records=self.min_back_records) 1025 1026 # If we're using a disk cache, save things now 1027 if self.disk_cache: 1028 self.cache.save_to_disk(self.disk_cache) 1029 1030 ############################ 1031 def _run_websocket_server(self): 1032 """Start serving on the specified websocket.""" 1033 async def start_server(): 1034 logging.info('Starting WebSocketServer on port %d', self.port) 1035 try: 1036 extra_kwargs = {} 1037 if _WEBSOCKETS_HAS_HTTP11: 1038 # Handle plain HTTP GET requests on the WebSocket port. 1039 # Supports two routes for simple data retrieval without a 1040 # full WebSocket handshake (see issue #367): 1041 # 1042 # GET /fields 1043 # Returns JSON list of all cached field names. 1044 # Example: {"fields": ["Temp", "Pressure", ...]} 1045 # 1046 # GET /latest/<field1>[,<field2>,...] 1047 # Returns the most recent timestamp+value for each 1048 # requested field. 1049 # Example: {"Temp": {"timestamp": 1234567890.0, 1050 # "value": 21.3}, 1051 # "Pressure": null} <- null if not cached 1052 # 1053 # All other non-WebSocket requests receive a 400 response. 1054 # Proxy warnings are preserved from the original handler. 1055 async def _handle_non_ws_request(connection, request): 1056 upgrade = request.headers.get('Upgrade', '') 1057 if upgrade.lower() == 'websocket': 1058 return None # let normal WebSocket upgrade proceed 1059 1060 via = request.headers.get('Via', '') 1061 if via: 1062 logging.warning( 1063 'WebSocket upgrade on port %d was blocked by an HTTP ' 1064 'proxy (Via: %s) which stripped the Upgrade header. ' 1065 'WebSocket connections cannot be established through ' 1066 'this proxy. Fix: enable SSL/WSS, configure the proxy ' 1067 'to pass WebSocket upgrades, or bypass the proxy for ' 1068 'this host.', 1069 self.port, via) 1070 1071 path = request.path 1072 1073 if path == '/fields': 1074 body = json.dumps( 1075 {'fields': sorted(self.cache.keys())} 1076 ).encode() 1077 return _WsResponse( 1078 200, 'OK', 1079 _WsHeaders([('Content-Type', 'application/json')]), 1080 body) 1081 1082 if path.startswith('/latest/'): 1083 field_names = path[len('/latest/'):].split(',') 1084 result = {} 1085 with self.cache.data_lock: 1086 for field in field_names: 1087 entries = self.cache.data.get(field) 1088 if entries: 1089 timestamp, value = entries[-1] 1090 result[field] = { 1091 'timestamp': timestamp, 1092 'value': value, 1093 } 1094 else: 1095 result[field] = None 1096 body = json.dumps(result).encode() 1097 return _WsResponse( 1098 200, 'OK', 1099 _WsHeaders([('Content-Type', 'application/json')]), 1100 body) 1101 1102 logging.debug( 1103 'Plain HTTP request on WebSocket port %d ' 1104 '(path %r is not a recognised CDS HTTP route). ' 1105 'Check nginx proxy_set_header Upgrade config.', 1106 self.port, path) 1107 return _WsResponse( 1108 400, 'Bad Request', 1109 _WsHeaders([('Content-Type', 'text/plain')]), 1110 b'Expected a WebSocket upgrade or a recognised CDS ' 1111 b'HTTP route (/fields, /latest/<field,...>)\n') 1112 1113 extra_kwargs['process_request'] = _handle_non_ws_request 1114 1115 self.websocket_server = await websockets.serve( 1116 self._serve_websocket_data, 1117 host='', 1118 port=self.port, 1119 **extra_kwargs 1120 ) 1121 logging.info('WebSocket server running on port %d', self.port) 1122 await self.websocket_server.wait_closed() 1123 except OSError as e: 1124 logging.fatal('Failed to open websocket on port %d: %s', self.port, e) 1125 raise e 1126 1127 # Use asyncio.run to manage the event loop 1128 asyncio.run(start_server()) 1129 1130 ############################ 1131 def quit(self): 1132 """Exit the loop and shut down all loggers. 1133 """ 1134 # Close any connections 1135 with self._connection_lock: 1136 self.quit_flag = True 1137 1138 for connection in self._connections: 1139 connection.quit() 1140 logging.info('WebSocketServer closed') 1141 1142 # Stop the event loop that's serving connections 1143 self.event_loop.stop() 1144 1145 # Wait for thread that's running the server to finish 1146 self.server_thread.join() 1147 1148 ############################ 1149 """Top-level coroutine for running CachedDataServer.""" 1150 async def _serve_websocket_data(self, websocket, unused_loop_arg=None): 1151 # Legacy websocket code passes in event loop as third argument; we don't need it, 1152 # but include so code works both pre and post WS14. 1153 1154 # Here is where we see the anomalous behavior - when constructed 1155 # directly, self.cache is as it should be: a shared cache. But 1156 # when invoked indirectly, e.g. as part of a listener via 1157 # 1158 # listener = ListenerFromLoggerConfig(config) 1159 # proc = multiprocessing.Process(target=listener.run, daemon=True) 1160 # proc.start() 1161 # 1162 # then self.cache always appears ins in its initial (empty) state. 1163 connection = WebSocketConnection(websocket, self.cache, self.interval) 1164 1165 # Stash the connection so we can tell it to exit when we receive a 1166 # quit(). But first do some cleanup, getting rid of old 1167 # connections that have closed. 1168 with self._connection_lock: 1169 index = 0 1170 while index < len(self._connections): 1171 if self._connections[index].closed(): 1172 logging.debug('Disposing of closed connection.') 1173 self._connections.pop(index) 1174 else: 1175 index += 1 1176 # Now add the new connection 1177 self._connections.append(connection) 1178 1179 # If client disconnects, tell connection to quit 1180 try: 1181 await connection.serve_requests() 1182 except ConnectionClosed: 1183 logging.warning('client disconnected') 1184 except KeyboardInterrupt: 1185 logging.warning('Keyboard Interrupt') 1186 1187 connection.quit() 1188 await websocket.close()
Class that caches field:value pairs passed to it in either a DASRecord or a simple dict. It also establishes a websocket server on the specified port and serves the cached values to clients that connect via a websocket.
WebSocket API:
The server listens for two types of requests:
- If the request is the string "variables", return a list of the names of the variables the server has in cache and is able to serve. The server will continue listening for follow up messages, most likely this one:
- If the request is a python dict, assume it is of the form:
{field_1_name: {'seconds': num_secs},
field_2_name: {'seconds': num_secs},
...}
where seconds is a float representing the number of seconds of back data being requested. This field dict is passed to serve_fields(), which will to retrieve num_secs of back data for each of the specified fields and return it as a JSON-encoded dict of the form:
{
field_1_name: [(timestamp, value), (timestamp, value), ...],
field_2_name: [(timestamp, value), (timestamp, value), ...],
...
}
The server will then await a "ready" message from the client, and when received, will loop and send a JSON-encoded dict of all the (timestamp, value) tuples that have come in since the previous request. It will continue this behavior indefinitely, waiting for a "ready" request and sending updates.
HTTP GET API (requires websockets >= 12):
Plain HTTP GET requests are also accepted on the same port, allowing simple tools to retrieve the latest cached value for one or more fields without a WebSocket handshake:
GET /fields Returns {"fields": ["field_1", "field_2", ...]}
GET /latest/
CAUTION: HTTP GET requests are handled inside the same asyncio event loop that drives all WebSocket connections. Each request briefly acquires the cache's threading lock, blocking the loop for the duration of the lookup. For occasional one-off queries (e.g. populating an elog entry) this is negligible. High-frequency polling from scripts or automated tools will delay WebSocket data delivery to all connected clients. Use the WebSocket subscription API for any use case that requires frequent or continuous updates.
937 def __init__( 938 self, 939 port, 940 interval=1, 941 back_seconds=60 * 60, 942 max_records=60 * 24, 943 min_back_records=100, 944 cleanup_interval=60, 945 disk_cache=None): 946 """ 947 port Port on which to serve websocket connections 948 interval How frequently to serve updates 949 back_seconds 950 How many seconds of back data to retain 951 max_records 952 Maximum number of records to store for each variable 953 min_back_records 954 Minimum number of back records to keep when purging old data 955 cleanup_interval 956 How many seconds between calls to cleanup old cache entries 957 and save to disk (if disk_cache is specified) 958 disk_cache If not None, name of directory in which to backup values 959 from in-memory cache 960 """ 961 self.port = port 962 self.interval = interval 963 self.back_seconds = back_seconds 964 self.max_records = max_records 965 self.min_back_records = min_back_records 966 self.cleanup_interval = cleanup_interval 967 968 self.cache = RecordCache() 969 970 # If they've given us the name of a disk cache, try loading our 971 # RecordCache from it. 972 self.disk_cache = disk_cache 973 if disk_cache: 974 self.cache.load_from_disk(disk_cache) 975 976 # List where we'll store our websocket connections so that we can 977 # keep track of which are still open, and signal them to close 978 # when we're done. 979 self._connections = [] 980 self._connection_lock = threading.Lock() 981 982 self.quit_flag = False 983 984 # Start a thread to loop through, cleaning up the cache and (if we've 985 # been given a disk_cache file) backing memory up to it. 986 threading.Thread(target=self.cleanup_loop, daemon=True).start() 987 988 # Fire up the thread that's going to the websocket server in our 989 # event loop. Calling quit() it will close any remaining 990 # connections and stop the event loop, terminating the server. 991 self.server_thread = threading.Thread( 992 target=self._start_event_loop, daemon=True) 993 self.server_thread.start()
port Port on which to serve websocket connections interval How frequently to serve updates back_seconds How many seconds of back data to retain max_records Maximum number of records to store for each variable min_back_records Minimum number of back records to keep when purging old data cleanup_interval How many seconds between calls to cleanup old cache entries and save to disk (if disk_cache is specified) disk_cache If not None, name of directory in which to backup values from in-memory cache
1011 def cache_record(self, record): 1012 """Cache the passed record.""" 1013 self.cache.cache_record(record)
Cache the passed record.
1016 def cleanup_loop(self): 1017 """Clear out records older than oldest seconds.""" 1018 while not self.quit_flag: 1019 time.sleep(self.cleanup_interval) 1020 1021 # What's the oldest record we should retain? 1022 oldest = time.time() - self.back_seconds 1023 self.cache.cleanup(oldest=oldest, max_records=self.max_records, 1024 min_back_records=self.min_back_records) 1025 1026 # If we're using a disk cache, save things now 1027 if self.disk_cache: 1028 self.cache.save_to_disk(self.disk_cache)
Clear out records older than oldest seconds.
1131 def quit(self): 1132 """Exit the loop and shut down all loggers. 1133 """ 1134 # Close any connections 1135 with self._connection_lock: 1136 self.quit_flag = True 1137 1138 for connection in self._connections: 1139 connection.quit() 1140 logging.info('WebSocketServer closed') 1141 1142 # Stop the event loop that's serving connections 1143 self.event_loop.stop() 1144 1145 # Wait for thread that's running the server to finish 1146 self.server_thread.join()
Exit the loop and shut down all loggers.