import json import os from datetime import datetime from typing import List, Optional, Union import pytz from elasticsearch import helpers from structlog import get_logger from delphi_es_utils.constants import INDEX_MAPPING_FILES, INDEX_PG_ID_COL from delphi_es_utils.entities import TaskResult from delphi_es_utils.errors.exceptions import ( AliasNotCreatedError, AliasNotUpdatedError, IndexMappingFilenameError, IndexNotCreatedError, IndexNotFoundError, IndexSettingsUpdateError, PostReindexError, ReIndexRequestError, SetLifecyclePolicyByAliasError, TaskResultError, ) from delphi_es_utils.repository.es_client import get_es_client from delphi_es_utils.settings import ( ELASTICSEARCH_HOST, ELASTICSEARCH_PORT, ES_BULK_CHUNK_SIZE, ES_REINDEX_SCROLL_SIZE, INDEX_MAPPINGS_PATH, LIFECYCLE_POLICY_ID, ) LOG = get_logger(__name__) class IndexManager: def __init__( self, index_alias='', use_remote=False, host=ELASTICSEARCH_HOST, port=ELASTICSEARCH_PORT ): """ Class to encapsulate Elasticsearch operations, including bulk indexing and reindexing. - Use :meth:`bulk_index` after initialization to run a bulk indexing job. - Use :meth:`reindex` after initialization to start a reindex task. - Class can also easily be used for common Elasticsearch operations. **Important note about using remote hosts**: - Remote hosts have to be explicitly whitelisted in ``elasticsearch.yml`` using the ``reindex.remote.whitelist`` property. See `Reindex from Remote`_. - There is currently **no support** for using a remote host on AWS ELK (April 2019) .. _Reindex from Remote: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html #reindex-from-remote .. _URL Parameters: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html #docs-bulk-api-query-params Args: index_alias(str): The name (alias) of the source index (ex: ``linkfire-link``). Omitting the index_name is useful if only using task-related methods. use_remote(bool): (optional) Pass True to use a remote Elasticsearch host. default: False host(str or None): (optional) Remote hostname to override environment variable port(str or int or None): (optional) Remote port number to override environment variable """ self.index_alias = index_alias datetime_fmt = '%Y-%m-%dt%H-%M-%S.%fz' new_index_datetime = datetime.now(tz=pytz.utc) self.new_index = f'{self.index_alias}_{new_index_datetime.strftime(datetime_fmt)}' self.new_alias = f'new_{self.index_alias}' self.prev_alias = f'prev_{self.index_alias}' self.use_remote = use_remote self.port = port self.host = host self.client = get_es_client(self.host, self.port) self.full_task_id: str = '' @property def mapping_filename(self) -> str: """ Returns: Filename containing the mapping json for the specified index """ if self.index_alias in INDEX_MAPPING_FILES: filename = INDEX_MAPPING_FILES.get(self.index_alias) if filename: return filename raise IndexMappingFilenameError('Could not find a mapping file based on index name') @property def mapping(self) -> dict: """Load an Elasticsearch mapping file based on the index name passed Returns: Serialized JSON from the mapping file """ mappings_file = os.path.join(INDEX_MAPPINGS_PATH, self.mapping_filename) if not os.path.isfile(mappings_file): raise FileNotFoundError(f'No mapping file exists at {mappings_file}. Aborting.') with open(mappings_file, 'r') as file: return json.load(file) @property def bulk_params(self) -> dict: """`URL Parameters`_ to send with the bulk API request to Elasticsearch. Returns: Dictionary of `URL Parameters`_ """ return { '_source': False, 'chunk_size': ES_BULK_CHUNK_SIZE, 'max_retries': 3, } @property def reindex_payload(self) -> dict: """Payload body prepared for the HTTP reindex request""" body = { 'conflicts': 'proceed', 'dest': { 'index': self.new_index, 'op_type': 'create', } } source = { 'index': self.index_alias, # scroll batch size 'size': ES_REINDEX_SCROLL_SIZE, } if not self.use_remote: return body # a remote host was specified, update the payload with the remote host url host = self.host.lower().rstrip('/') port = self.port host_url = f'https://{host}:{port}' if not host.startswith('http') else f'{host}:{port}' remote = {'remote': {'host': host_url, }} # type: ignore source.update(remote) body.update({'source': source}) return body @property def reindex_params(self) -> dict: """`URL Parameters`_ to send with the reindex HTTP request to Elasticsearch. Returns: Dictionary of `URL Parameters`_ """ return { 'wait_for_completion': False, } @property def task_result(self) -> Optional[TaskResult]: """Get's the status of a running task via the Task API Returns: Refreshed Task object of the initial reindex request Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ if not self.full_task_id: return None return self.get_status(self.full_task_id) def bulk_index(self, items: List[dict], stats_only=True) -> dict: """Main function to perform a bulk indexing process. Returns: Dictionary containing number of success, failed, actions, and errors if stats_only=True Raises: AliasNotCreatedError: Failed to create or update alias IndexNotCreatedError: Failed to create a new index IndexSettingsUpdateError: Unable to update index settings TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ if not items: raise AssertionError('No actions provided for indexing!') actions = self.build_es_actions(self.new_index, items=items) wildcard_index = f'{self.index_alias}*' if not self.add_index_alias(wildcard_index, self.prev_alias): raise AliasNotCreatedError( 'Failed to add previous aliases to existing indices.' f'alias={self.prev_alias}, index={wildcard_index}' ) if not self.create_index(): raise IndexNotCreatedError(f'Failed to create index with name "{self.new_index}') if not self.enable_bulk_index_settings(self.new_index): raise IndexSettingsUpdateError( 'Failed to enable bulk index performance settings on %s' % self.new_index ) success, err = helpers.bulk( self.client, actions=actions, stats_only=stats_only, **self.bulk_params ) if not self.disable_bulk_index_settings(self.new_index): raise IndexSettingsUpdateError( 'Failed to disable bulk index performance settings on %s' % self.new_index ) if isinstance(err, list): errors = err failed = len(err) else: errors = [] failed = err result = { 'actions': len(items), 'success': success, 'failed': failed, 'errors': errors, 'success_pct': (len(items) / success) * 100 if success else 0 } if success or not errors: # update alias if we have at least one success or no errors if not self.alias_new_index(): raise AliasNotCreatedError( 'Failed to update the "new" alias. ' f'alias={self.new_alias}, index={self.new_index}' ) if not self.update_main_alias(): raise AliasNotUpdatedError( 'Failed to update the main alias. ' f'alias={self.index_alias}, index={self.new_index}' ) if not self.set_lifecycle_policy_by_alias(): raise SetLifecyclePolicyByAliasError( 'Failed to set lifecycle policy to alias. ' f'alias={self.prev_alias}, policy={LIFECYCLE_POLICY_ID}' ) else: LOG.warning( 'Aliases not updated due to unsuccessful bulk index operation. ' f'alias={self.new_alias}, index={self.new_index}' ) return result @classmethod def build_es_actions(cls, index_name, items: List[dict]): """Creates Elasticsearch actions from items""" id_field = INDEX_PG_ID_COL.get(index_name) actions = [] for item in items: bulk_item = { '_op_type': 'create', '_index': index_name, '_id': item.get(id_field), '_source': item } actions.append(bulk_item) return actions def enable_bulk_index_settings(self, index_name: str) -> Union[dict, bool]: """Enable settings on the destination index for bulk performance during indexing Returns: Response or False if failed Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ body = {'index': {'refresh_interval': '-1', }} return self.client.indices.put_settings(body=body, index=index_name) def disable_bulk_index_settings(self, index_name: str) -> Union[dict, bool]: """Disable bulk performance settings on the destination index set earlier during indexing Returns: Response or False if failed Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ body = {'index': {'refresh_interval': '1s', }} return self.client.indices.put_settings(body=body, index=index_name) def add_index_alias(self, index_name, alias_name) -> dict: """Adds index Args: index_name: Index name alias_name: Alias name Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument PostReindexError: if attempting to change aliases during a reindex operation """ body = {'actions': [{'add': {'index': index_name, 'alias': alias_name}}]} return self.client.indices.update_aliases(body=body) def update_main_alias(self) -> dict: """Update main index alias pointer Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument PostReindexError: if attempting to change aliases during a reindex operation """ body = { 'actions': [ { 'remove': { 'index': '*', 'alias': self.index_alias, } }, { 'add': { 'index': self.new_index, 'alias': self.index_alias, 'is_write_index': True, } } ] } return self.client.indices.update_aliases(body=body) def source_exists(self) -> bool: """Checks if our source index exists before proceeding Returns: True if the source index specified exists in the source Elasticsearch instance Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ return self.client.indices.exists(self.index_alias) def create_index(self) -> dict: """Create a new Elasticsearch index Returns: Data from ES response Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ return self.client.indices.create(self.new_index, body=self.mapping) def alias_new_index(self) -> dict: """Remove any existing alias with :attr:`new_index_name` and point the alias to the newly created index. Returns: Serialized JSON response from the update aliases request, or False if failed. Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ body = { 'actions': [ { 'remove': { 'index': '*', 'alias': self.new_alias, } }, { 'add': { 'index': self.new_index, 'alias': self.new_alias, } } ] } return self.client.indices.update_aliases(body=body) def reindex(self) -> bool: """Main function to begin the reindexing process. Creates a reindex task when run. Returns: True if reindex task started successfully Raises: AliasNotCreatedError: Failed to create or update alias IndexNotCreatedError: Failed to create a new index IndexNotFoundError: Source index specified does not exist IndexSettingsUpdateError: Unable to update index settings ReIndexRequestError: Reindex request failed to return a valid response TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ if not self.source_exists(): raise IndexNotFoundError(f'Source index does not exist with name "{self.index_alias}"') if not self.create_index(): raise IndexNotCreatedError(f'Failed to create index with name "{self.new_index}') if not self.alias_new_index(): raise AliasNotCreatedError( f'Failed to update alias "{self.new_alias}" to "{self.new_index}"' ) if not self.enable_bulk_index_settings(self.new_index): raise IndexSettingsUpdateError( 'Failed to enable bulk index performance settings on %s' % self.new_index ) # pylint: disable=unexpected-keyword-arg task_response = self.client.reindex(body=self.reindex_payload, **self.reindex_params) if not task_response or not task_response.get('task'): raise ReIndexRequestError('Reindex request failed to return a valid response') self.full_task_id = task_response.get('task') return True def cancel_task(self, full_task_id: str) -> dict: """Cancel a running task. Args: full_task_id: Full task id in format ``node:task_id`` Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ return self.client.tasks.cancel(task_id=full_task_id) def get_status(self, full_task_id: str) -> TaskResult: """Get an existing task. Args: full_task_id: Full task id in format ``node:task_id`` Returns: TaskResult object Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ data = self.client.tasks.get(task_id=full_task_id) if not data: raise TaskResultError(f'Failed requesting task "{full_task_id}" via the Task API') return TaskResult(data) def post_reindex(self, index_name: str, full_task_id=None) -> dict: """Cleans up reindex job Args: index_name: Index (alias) name full_task_id(str or None): Full task id in format ``node:task_id`` Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument PostReindexError: if attempting to change aliases during a reindex operation """ if full_task_id: result = self.get_status(full_task_id) if not result.completed: raise PostReindexError( f'Unable to move aliases: ' f'The reindexing task is incomplete: {full_task_id}' ) if not self.disable_bulk_index_settings(index_name): raise IndexSettingsUpdateError( 'Failed to disable bulk index performance settings on %s' % index_name ) return self.update_main_alias() def rollback_main_alias(self, index_name: str) -> dict: """Rolls back to previous alias pointers before :meth:`update_index_aliases` was run Args: index_name: Index (alias) name Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ # pylint: disable=fixme # todo: implement if needed raise NotImplementedError('Support TBA') def set_lifecycle_policy_by_alias(self) -> dict: """Set lifecycle policy to indexes by alias Returns: Elasticsearch serialized JSON response data Raises: TransportError: Elasticsearch HTTP Transport Error ValueError: if an empty value is passed for a required argument """ body = {'policy_id': LIFECYCLE_POLICY_ID} return self.client.transport.perform_request( 'POST', f'/_opendistro/_ism/add/{self.prev_alias}*', body=body )