"""Jobs (order details) model.""" import json from ddtrace import tracer from opensearchpy import helpers as os_helpers from opensearchpy.exceptions import OpenSearchException import sqlalchemy from src.connectors import direct_delivery from src.connectors import logging from src.connectors.opensearch import os_client from src.constants import fields from src.constants import http_statuses from src.constants import opensearch as os_consts from src.models import sql_queries from src.utils import timezone def _get_query(field, value): """Build and execute ES update by query operation. Args: field (str): update script field name value (object): new value Returns: str: ES script command """ if isinstance(value, str): value = '"{}"'.format(value) if isinstance(value, bool): value = str(value).lower() return os_consts.SOURCE_COMMAND_TEMPLATE.format( field=field, value=value) def _get_body(source, query_dict): """Generate update_by_query body from template. Args: source (str): Update script string. query_dict (dict): Query as dictionary. Returns: dict: ES update_by_query body """ return { 'script': {'source': source}, 'query': query_dict, } def _update_by_query(commands, conditions, **kwargs): """Build and execute OS update by query operation. All terms in the individual query condition are joined through a logical conjunction (AND). All individual inner queries are joined with a logical disjunction (OR). Conditions list is expected to have ES inner queries items lists. So conditions is of the following format: [ [ {"term": {"order_type": "track"}}, {"range": {"encoding_queue_detail_id": {"lt": 1111}}}, ... ], [ {"term": {"order_type": "release"}}, {"range": {"encoding_queue_detail_id": {"lt": 2222}}}, ... ], ] Args: commands (dict): Update script operations: key (str) - field name, value (object) - new value conditions (list): A list of filter conditions lists for individual queries. kwargs: Additional parameters passed to the update_by_query() call. Returns: dict: ES query result or None on empty parameters. """ if not commands: raise ValueError('Empty term conditions to perform update by query.') if not conditions: raise ValueError('Empty commands to perform update by query.') logging.logger.debug( f'Generating _update_by_query with {commands} expressions ' f'for {conditions}') source = ''.join( [_get_query(key, value) for key, value in commands.items()]) # Construct query by hand, as combining a large number of Q objects is # _very_ expensive (~60 seconds for 400 term_conditions in a 128MB Lambda). # Note: queries generated this way are verbose and could be simplified # for cases with single item lists, but this is not strongly required. query_dict = { 'bool': {'should': [ {'bool': {'must': list(inner_conditions)}} for inner_conditions in conditions]}} logging.logger.debug('Making OS _update_by_query request.') os_result = os_client.update_by_query( index=os_consts.OS_INDEX_VO_DETAIL, body=_get_body(source, query_dict), **kwargs ) logging.logger.debug(f'update_by_query result: {os_result}') return os_result def _get_id(record): """Generate ES detail key from prepared record fields. Args: record (dict): Prepared record with required ES key components. Returns: str: ES detail key. """ return '{:d}'.format(record[fields.ENCODING_QUEUE_DETAIL_ID]) def _extract_from_db_record(db_record): """Extract necessary data from EQD DB record. Args: db_record (dict): EQD DB record Returns: dict: modified DB record key (str) - field name, value (object) - field value """ record = dict(db_record) datetime_fields = ( fields.CREATED_AT, fields.ENCODING_STARTED, fields.ENCODING_ENDED, fields.DELIVERY_STARTED, fields.DELIVERY_ENDED) for f in datetime_fields: # Some fields might not be passed on update event. if f in record: record[f] = timezone.convert_to_utc_str(record[f]) record[fields.STORE_ID] = record.pop(fields.DMS_MASTER_MASTER_ID) # Note: fields.IS_DUPLICATE flag for actual duplicates must be set after # inserting all the records. # It is set here to False for metadata updates only, as they won't be # selected later for updating duplicate flag (they are never treated as # duplicates). if record[fields.META_UPDATE]: record[fields.IS_DUPLICATE] = False return record def _get_only_necessary_fields(record, fields_only): """Get a subset of fields for specific operation. Args: record (dict): EQD record fields_only (set): necessary fields for specific operation Returns: dict: filtered record key (str) - field name, value (object) - field value """ return { key: value for key, value in record.items() if key in fields_only } def _prepare_records(records, fields_list): """Prepare records by filtering unnecessary fields and generating IDs. Args: records (iterable): Records to be inserted in OpenSearch. fields_list (set): A set of fields to include in each record. Returns: list: A list of tuples (document ID string, document body dictionary). """ prepared_records = [] for r in records: prepared_fields = _extract_from_db_record(r) document_id = _get_id(prepared_fields) document_body = _get_only_necessary_fields( prepared_fields, fields_list) prepared_records.append((document_id, document_body)) return prepared_records @tracer.wrap() def create_bulk(records): """Create OpenSearch detail records. Args: records (iterable): OpenSearch records as dicts. """ logging.logger.debug('Starting bulk insertion of OS records.') os_actions = ({ '_op_type': 'index', # 'index' allows to ignore conflicts on insert. '_index': os_consts.OS_INDEX_VO_DETAIL, '_type': os_consts.DOCUMENT_TYPE, '_id': doc_id, '_source': doc_body, } for doc_id, doc_body in _prepare_records( records, os_consts.VO_DETAIL_ADD)) # With OS alias, no need to wait for create_bulk() to get sync. # Even the next update call on same alias finds the document. # We cannot do the same for update_bulk() since we need the not found stats. os_successful, os_errors = os_helpers.bulk( client=os_client, actions=os_actions, max_retries=os_consts.MAX_BULK_RETRIES, stats_only=True, ) logging.logger.debug( 'Finished bulk insertion of OS records.' f'successful_creates: {os_successful} ' f'and errors: {os_errors}') def update_priority(order_id, priority): """Update priority value for all order details (jobs). Args: order_id (int): order id priority (int): new priority value """ result = _update_by_query( commands={fields.PRIORITY: priority}, conditions=[[{'term': {fields.ORDER_ID: order_id}}]], conflicts='proceed', # Ignore internal ES record version conflicts. wait_for_completion=False, ) logging.logger.debug( 'OpenSearch task ID for updating priority: %s', result['task']) def set_duplicates_bulk(duplicates, non_duplicates): """Set is_duplicate flag for given list of records. Note: records should have at least fields used to compose the ES _id. Args: duplicates (iterable): A list of duplicate records as dictionaries. non_duplicates (iterable): A list of not duplicate records as dictionaries. """ logging.logger.debug('Starting checking/setting duplicate flags in ES.') for records, is_duplicate in (duplicates, True), (non_duplicates, False): if not records: logging.logger.debug( 'Empty records list to set is_duplicate = %s.', is_duplicate) continue # Only one condition, but with multiple keys. condition = [ { 'ids': { # Updating by _id should be faster than filtering. 'type': os_consts.DOCUMENT_TYPE, 'values': [_get_id(r) for r in records], } }, ] logging.logger.debug( 'Starting _update_by_query for is_duplicate = %s.', is_duplicate) # 'conflicts=proceed' is allowed here as possible changes to the target # records of this query should not affect 'is duplicate' condition # logic. _update_by_query( commands={fields.IS_DUPLICATE: is_duplicate}, conditions=[condition], conflicts='proceed') logging.logger.debug('Finished duplicate flags in ES.') def set_store_spec(order_type, store_id, encoding, delivery): """Apply new delivery/encoding values to details (jobs). Args: order_type (str): order type store_id (int): detail (job) store id encoding (str): new encoding flag value delivery (str): new delivery flag value """ result = _update_by_query( commands={fields.ENCODING: encoding, fields.DELIVERY: delivery}, conditions=[ [ {'term': {fields.STORE_ID: store_id}}, {'term': {fields.ORDER_TYPE: order_type}}, ]], conflicts='proceed', # Ignore internal ES record version conflicts. wait_for_completion=False, ) logging.logger.debug( 'OpenSearch task ID for updating store spec: %s', result['task']) @tracer.wrap() def update_bulk(records): """Update OpenSearch detail records. Args: records (iterable): OpenSearch records as dicts. Returns: tuple: A tuple with number of successful updates and a list of not found document records. Raises: OpenSearchException: On unexpected error status. """ operation = 'update' record_key = 'record' action_key = 'action' os_actions_dict = { doc_id: { # We'll need this key to get not found records. record_key: full_record, action_key: { '_op_type': operation, '_index': os_consts.OS_INDEX_VO_DETAIL, '_type': os_consts.DOCUMENT_TYPE, '_id': doc_id, '_source': {'doc': doc_body}, } } for (doc_id, doc_body), full_record in zip( _prepare_records(records, os_consts.VO_DETAIL_UPDATE), records) } os_actions = (v[action_key] for v in os_actions_dict.values()) os_successful, os_errors = os_helpers.bulk( client=os_client, actions=os_actions, raise_on_error=False, max_retries=os_consts.MAX_BULK_RETRIES) # not_found_records will be passed by logic layer to create_bulk(). not_found_records = [] for error in os_errors: # 404 errors are expected because we update optimistically. if error[operation]['status'] == http_statuses.NOT_FOUND: doc_id = error[operation]['_id'] # Get original (full) record dictionary by the doc_id. not_found_records.append(os_actions_dict[doc_id][record_key]) else: logging.logger.error( f'Unexpected update_bulk status. Error: {json.dumps(error)}') raise OpenSearchException( 'Unexpected error status for update operation: {}', error[operation]) logging.logger.debug( f'Finished update_bulk for operation: {operation}, ' f'successful_updates: {os_successful} ' f'and not_found_records: {len(not_found_records)}' ) return os_successful, not_found_records def get_encoding_queue_detail_bulk(encoding_queue_detail_ids): """Get dms_delivery_spec_detail records by PK. Args: encoding_queue_detail_ids (iterable): Integer PK values for the encoding_queue_detail table. Returns: dict: A two-level dict with PK as a key and a subset records as nested dicts. Example: { 149331715: {'dms_master_master_id': 1, 'upc': 192562479902, ...}, 149331716: {'dms_master_master_id': 286, 'upc': 192562479901, ...}, } """ # There are just about 550 possible records in this table but we can # receive an input searching just for a few records, so we're concatenating # all the conditions. (Another option could be just returning all the table # records.) if not encoding_queue_detail_ids: return {} result_dict = {} # remove dupe ids encoding_queue_detail_ids = list(set(encoding_queue_detail_ids)) with direct_delivery.session_scope() as session: result = session.execute( sqlalchemy.text(sql_queries.DD_SELECT_ENCODING_QUEUE_DETAIL_BULK), {fields.SQL_ID_LIST: encoding_queue_detail_ids}) for row in result.mappings().all(): row_dict = dict(row) result_dict[row_dict[fields.ENCODING_QUEUE_DETAIL_ID]] = row_dict return result_dict