import json import re import uuid from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterable, List, Optional import boto3 import structlog from boto3_type_annotations.lambda_ import Client as LambdaClient from boto3_type_annotations.s3.paginator import ListObjectsV2 from db_schema.schemas.apps import UnitOfWorkPriorityEnum as AppsUnitOfWorkPriorityEnum from db_schema.schemas.slz import ContentStatus, UnitOfWork from db_schema.schemas.slz import UnitOfWorkPriorityEnum as SlzUnitOfWorkPriorityEnum from slz_notification.entities import Message, Metadata from slz_notification.sqs_service import SQSNotificationService from sqlalchemy.orm import Session from delphi_slz_admin.const import APP_NAME from delphi_slz_admin.helpers import utcnow from delphi_slz_admin.services.config import ReprocessingConfig from delphi_slz_admin.services.const import TIMESLOT_DT_PATTERN, DSPEnum from delphi_slz_admin.services.entities import TimeslotDateTimeRange logger = structlog.get_logger(APP_NAME) class ReprocessingLambdaInvalidResponse(Exception): pass class ReprocessingService: def __init__(self, db_session: Session) -> None: self.db_session = db_session self.lambda_client: LambdaClient = boto3.client('lambda') @staticmethod def _build_slz_payload( uows: Iterable[UnitOfWork], priority: Optional[SlzUnitOfWorkPriorityEnum], ) -> Dict[str, Any]: reprocessing = [{ 'uow_id': uow.unit_of_work_code, 'unit_of_work_id': uow.unit_of_work_id, 'priority': priority.value if priority else SlzUnitOfWorkPriorityEnum.DEFAULT.value } for uow in uows] return { 'account': '{{account-id}}', 'detail': {}, 'detail-type': 'Scheduled Event', 'id': str(uuid.uuid4()), 'region': 'us-east-1', 'reprocessing': reprocessing, 'resources': ['arn:aws:events:us-east-1:123456789012:rule/ExampleRule'], 'source': 'aws.events', 'time': utcnow().replace(microsecond=0).isoformat(), } @staticmethod def _build_apps_payload( uow_ids: List[int], priority: Optional[AppsUnitOfWorkPriorityEnum], driver_options: Optional[List[str]], ) -> Dict[str, Any]: reprocessing = [{ 'unit_of_work_id': uow_id, 'priority': priority.value } for uow_id in uow_ids] if priority is not None else [{ 'unit_of_work_id': uow_id } for uow_id in uow_ids] result = { 'initialize': False, 'time': utcnow().replace(microsecond=0).isoformat(), 'data_sources': ['slz', 'chartmetric'], 'reprocessing': reprocessing, } if driver_options: result['add_driver_java_options'] = driver_options return result def _invoke_function(self, function_name: str, payload: Dict[str, Any]) -> None: logger.debug('Invoking lambda', extra={'function_name': function_name, 'payload': payload}) response = self.lambda_client.invoke( FunctionName=function_name, Payload=json.dumps(payload).encode(), InvocationType='Event' ) logger.debug('Lambda response', extra={'response': response}) if response['StatusCode'] != 202: raise ReprocessingLambdaInvalidResponse def reprocess_slz_uows( self, content_status_ids: List[int], priority: Optional[SlzUnitOfWorkPriorityEnum], env: str, ) -> List[str]: query = self.db_session.query(ContentStatus).filter( ContentStatus.content_status_id.in_(content_status_ids), ) uows = {content_status.unit_of_work for content_status in query.all()} if not uows: return [] payload = self._build_slz_payload(uows, priority) function_name = f'{env}-delphi-slz-job-manager-{env}' self._invoke_function(function_name, payload) return [uow.unit_of_work_code for uow in uows] def reprocess_apps_uows( self, unit_of_work_ids: List[int], priority: Optional[SlzUnitOfWorkPriorityEnum], driver_options: Optional[List[str]], env: str, ) -> int: payload = self._build_apps_payload(unit_of_work_ids, priority, driver_options) function_name = f'{env}-delphi-apps-etl-manager-{env}' self._invoke_function(function_name, payload) return len(unit_of_work_ids) class ExplorationReprocessingService: # dsp where could be several files per 1 context DSP_MULTIFILES = ['apple'] REPORT_TYPE_MAPPING = { 'apple': { 'amStreams': 'streams', 'amSummaryStreams': 'summarystreams', 'amContent': 'content', 'amEvent': 'event', 'amContentDemographics': 'contentdemographics', 'amArtistDemographics': 'artistdemographics', 'amNonRoyaltyStreams': 'nonroyaltystreams', 'amNonRoyaltySummaryStreams': 'nonroyaltysummarystreams', 'amShazam': 'shazam', 'amArtists': 'artists', 'amPlaylists': 'playlists', 'amSongs': 'songs', 'amLibraryEvents': 'libraryevents', 'amTotalLibraryAdds': 'totallibraryadds', 'amMarketshare': 'marketshare', 'amContainer': 'container', } } def __init__(self) -> None: self.sqs_service = SQSNotificationService(logger, boto3.client('sqs')) self.paginator: ListObjectsV2 = boto3.client('s3').get_paginator('list_objects_v2') @staticmethod def _get_filename_part(content_status: ContentStatus) -> str: data_source_id = content_status.unit_of_work.report.data_source_id if data_source_id == DSPEnum.APPLE.value: return content_status.context.split('::')[1] if data_source_id in [DSPEnum.LINKFIRE.value, DSPEnum.YOUTUBEREPORTING.value]: return content_status.unit_of_work.report.report_name return content_status.context # pylint: disable=too-many-locals def _find_keys( self, bucket: str, uow_code: str, filename_part: str, timeslot: Optional[TimeslotDateTimeRange], ) -> str: """ Scan s3 bucket for required file based on prefix generated from UOW_id and filename_part(usually it's context) """ result = [] dsp, date, licensor, report_type, version = uow_code.split('-') date_obj = datetime.strptime(date, '%Y%m%d') year, month, day = date_obj.year, str(date_obj.month).zfill(2), str(date_obj.day).zfill(2) # for some dsps report_type name in folder differs r_type = self.REPORT_TYPE_MAPPING.get(dsp, {}).get(report_type, report_type) params = { 'Bucket': bucket, 'Prefix': f'{dsp}/{r_type}/{version}/report_date={year}-{month}-{day}/' f'report_licensor={licensor}/' } page_iterator = self.paginator.paginate(**params) for page in page_iterator: keys = page.get('Contents', []) for key in keys: k = key['Key'] path = Path(k) if filename_part in path.name: full_path = f's3://{bucket}/{k}' result.append(full_path) if dsp not in self.DSP_MULTIFILES and len(result) > 1: result = self._regexp_key_check(result, uow_code, filename_part, timeslot) return ','.join(result) @staticmethod def _regexp_key_check( s3_keys: List[str], uow_code: str, filename_part: str, timeslot: Optional[TimeslotDateTimeRange], ) -> List[str]: # for some dsps, like AE, context names could be similar. Like BR/BR_FiltrStore # regexp_rules are common naming patterns used in SLZ. _, date, __, report_type, ___ = uow_code.split('-') timeslot_suffix = '' if timeslot: lower = timeslot.lower.strftime(TIMESLOT_DT_PATTERN) upper = timeslot.upper.strftime(TIMESLOT_DT_PATTERN) timeslot_suffix = f'_{lower}_{upper}' regexp_rules = [ f'^{filename_part}_{date}{timeslot_suffix}', f'_{filename_part}_{date}{timeslot_suffix}', f'{report_type}_{date}_{filename_part}{timeslot_suffix}', ] for rule in regexp_rules: filtered_results = [] for filename in s3_keys: if re.search(rule, filename.rsplit('/', maxsplit=1)[1]): filtered_results.append(filename) if len(filtered_results) == 1: break if len(filtered_results) > 1: logger.error('Multiple files were found for uow %s and cs %s', uow_code, filename_part) return [] return filtered_results def reprocess_cs(self, content_statuses: List[ContentStatus], config: ReprocessingConfig) -> Dict[int, str]: errors = {} for content_status in content_statuses: uow = content_status.unit_of_work filename_part = self._get_filename_part(content_status) timeslot = uow.timeslot if timeslot: timeslot = TimeslotDateTimeRange.from_dt_range(timeslot) metadata = Metadata( version='2.0.0', queues=[config.sqs_exp_split_notification_queue], message=Message( uow_id=uow.readable, unit_of_work_id=uow.unit_of_work_id, compressed_path=self._find_keys( bucket=config.slz_bucket_archive, uow_code=uow.unit_of_work_code, filename_part=filename_part, timeslot=timeslot, ), decompressed_paths=self._find_keys( bucket=config.slz_bucket_decompressed, uow_code=uow.unit_of_work_code, filename_part=filename_part, timeslot=timeslot, ), content_name=content_status.content_name, context=content_status.context, optional_config={} ) ) if not metadata.message.decompressed_paths: errors[content_status.content_status_id] = \ 'File is absent in decompressed bucket' continue _, err = self.sqs_service.push(metadata) if err: errors[content_status.content_status_id] = err return errors