"""Kinesis to Kafka Producer for Switchboard.""" import base64 import copy import json import uuid from collections import defaultdict from datetime import datetime from aws_kinesis_agg import deaggregator from lambdacommon.common_config import logger import config from src.connectors import kafka from src.connectors.graphql import get_orchard_client from src.logic.graphql import ( get_ows_product_by_release_artist_id, get_ows_product_by_release_id, get_ows_track_by_track_artist_id, get_ows_track_by_track_id, ) from src.logic.utils import get_project_id_from_gql_response, get_project_id_from_record DEFAULT_PRODUCT_LOOKUP = { "microservice_lookup": { "handler": get_ows_product_by_release_id, "arg_data_key": "release_id", "result_key": "upc", "include": get_project_id_from_gql_response, "exclude": lambda data: data["commercialType"] == "SPECIAL", } } PRODUCT_BY_RELEASE_ARTIST_LOOKUP = { "microservice_lookup": { "handler": get_ows_product_by_release_artist_id, "arg_data_key": "release_artist_id", "result_key": "upc", "exclude": lambda data: data["commercialType"] == "SPECIAL", } } DEFAULT_TRACK_LOOKUP = { "microservice_lookup": { "handler": get_ows_track_by_track_id, "arg_data_key": "track_id", "result_key": "isrc", } } TRACK_BY_TRACK_ARTIST_LOOKUP = { "microservice_lookup": { "handler": get_ows_track_by_track_artist_id, "arg_data_key": "track_artist_id", "result_key": "isrc", } } def get_track_lookup(key_id): """Get track lookup mapping.""" lookup = copy.deepcopy(DEFAULT_TRACK_LOOKUP) lookup["microservice_lookup"]["arg_data_key"] = key_id return lookup TRACKED_DB_ACTIONS = ("insert", "update", "delete") def get_handlers_mapping(): """Return handlers mapping.""" return { "PROJECT": { "project": { "on_insert_update_delete_local_id_key": "project_id", } }, "PRODUCT": { "releases": { "on_insert_update_delete_local_id_key": "release_id", "on_insert_update_delete_business_key": "display_upc", "include": get_project_id_from_record, "exclude": (lambda record: record["not_for_distribution"] != "N"), }, "release_localized_metadata": DEFAULT_PRODUCT_LOOKUP, "release_artist_localized_metadata": (PRODUCT_BY_RELEASE_ARTIST_LOOKUP), "release_artist": DEFAULT_PRODUCT_LOOKUP, "product_physical": DEFAULT_PRODUCT_LOOKUP, "product_video": DEFAULT_PRODUCT_LOOKUP, "track": DEFAULT_PRODUCT_LOOKUP, }, "TRACK": { "track": { "on_insert_update_delete_local_id_key": "id", "on_insert_update_delete_business_key": "isrc", }, "track_artist": get_track_lookup("track_id"), "track_publisher": get_track_lookup("unique_track_id"), "track_localized_metadata": get_track_lookup("track_id"), "track_artist_localized_metadata": (TRACK_BY_TRACK_ARTIST_LOOKUP), "track_writer": get_track_lookup("unique_track_id"), }, } def get_raw_events(record_data, graphql_switchboard_client): """Determine Maxwell record event types. Args: record_data (dict): Maxwell event body. graphql_switchboard_client (OrchardConnector): Graphql client. Returns: list(dict): List of raw events. """ logger.info(f"Processing record_data {record_data}") table_name = record_data["table"] table_action = record_data["type"] raw_events = [] if table_action not in TRACKED_DB_ACTIONS: return [] mapping = get_handlers_mapping() for event_type, event_tables_map in mapping.items(): if table_name in event_tables_map: table_mapping_details = event_tables_map[table_name] if table_mapping_details.get("microservice_lookup"): microservice_lookup = table_mapping_details["microservice_lookup"] handler = microservice_lookup["handler"] arg_data_key = microservice_lookup["arg_data_key"] arg_data = record_data["data"][arg_data_key] logger.info(f"Calling handler for {table_name}.") lookup_result = handler(arg_data, graphql_switchboard_client) raw_event = {"event_type": event_type, "ts": record_data["ts"]} if lookup_result: lookup_item_data = lookup_result[1] logger.info(f"GQL response: {lookup_item_data}.") # Add any included fields to additional_info. include_handler = microservice_lookup.get("include") if include_handler: logger.info("Include handler found.") include = include_handler(lookup_item_data) if include: logger.info(f"Included: {include}") raw_event["additional_info"] = include else: logger.info("No data found to include.") else: logger.info("No include handler found.") exclude = False if microservice_lookup.get("exclude"): exclude_handler = microservice_lookup.get("exclude") if exclude_handler(lookup_item_data): logger.error("Excluded: {}".format(record_data["data"])) exclude = True if not exclude: raw_event["id"] = lookup_result[0] raw_event["business_key"] = lookup_result[1][ microservice_lookup["result_key"] ] raw_events.append(raw_event) else: logger.error("Lookup failed") elif table_mapping_details.get("on_insert_update_delete_local_id_key"): local_id_key = table_mapping_details[ "on_insert_update_delete_local_id_key" ] local_id = record_data["data"][local_id_key] raw_event = { "event_type": event_type, "ts": record_data["ts"], "id": local_id, "business_key": None, } if table_mapping_details.get("on_insert_update_delete_business_key"): business_key_key = table_mapping_details[ "on_insert_update_delete_business_key" ] raw_event["business_key"] = record_data["data"][business_key_key] # Add any included fields to additional_info. include_handler = table_mapping_details.get("include") if include_handler: logger.info( "Include handler found. {}".format( json.dumps( {"record_data": record_data, "raw_event": raw_event} ) ) ) include = include_handler(record_data["data"]) if include: logger.info( "Included extra data {}".format( json.dumps( { "record_data": record_data, "raw_event": raw_event, "included_data": include, } ) ) ) raw_event["additional_info"] = include else: logger.info( "No data found to include. {}".format( json.dumps( { "record_data": record_data, "raw_event": raw_event, } ) ) ) else: logger.info( "No include handler found. {}".format( json.dumps( { "record_data": record_data, "raw_event": raw_event, } ) ) ) exclude = False if table_mapping_details.get("exclude"): exclude_handler = table_mapping_details["exclude"] if exclude_handler(record_data["data"]): logger.error("Excluded: {}".format(record_data["data"])) exclude = True if not exclude: raw_events.append(raw_event) else: logger.error("Handler not found") return raw_events def _format_datetime(dt): return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" def clean_raw_events(raw_events): """Group events of specific type by id setting latest timestamp.""" grouped_by_type_and_id = defaultdict(dict) for raw_event in raw_events: key = ( raw_event["event_type"], raw_event["id"], raw_event["business_key"], ) if grouped_by_type_and_id[key].get("ts"): grouped_by_type_and_id[key]["ts"].append(raw_event["ts"]) else: grouped_by_type_and_id[key]["ts"] = [raw_event["ts"]] grouped_by_type_and_id[key]["additional_info"] = raw_event.get( "additional_info" ) cleaned_events = [] for key, values in grouped_by_type_and_id.items(): (event_type, entity_id, business_key) = key ts_list = values["ts"] additional_info = values["additional_info"] cleaned_event = { "event_type": event_type, "id": entity_id, "ts": max(ts_list), "business_key": business_key, } if additional_info: cleaned_event["additional_info"] = additional_info cleaned_events.append(cleaned_event) return cleaned_events def enrich_message(raw_message): """Generate Kafka message body from Maxwell event. Args: raw_message (dict): Raw event body. Returns: dict: Generated Kafka message. """ message_id = str(uuid.uuid4()) entity_id = {"localId": str(raw_message["id"]), "system": "ORCHARD"} if raw_message["event_type"] == "PRODUCT": entity_id["businessKeyType"] = "UPC" entity_id["businessKey"] = raw_message["business_key"] if raw_message["event_type"] == "TRACK": entity_id["businessKeyType"] = "ISRC" entity_id["businessKey"] = raw_message["business_key"] if "businessKey" in entity_id and not entity_id["businessKey"]: logger.info( "Ignoring {} message due to missing businessKey {}".format( raw_message["event_type"], json.dumps({"correlation_id": message_id, "ids": [entity_id]}), ) ) return None message = { "sendingSystem": "ORCHARD", "entityType": raw_message["event_type"], "messageType": "METADATA_UPDATE", "messageId": message_id, "correlationId": message_id, "messageCreatedDateTime": _format_datetime(datetime.now()), "entityUpdatedDateTime": _format_datetime( datetime.utcfromtimestamp(raw_message["ts"]) ), "ids": [entity_id], } if raw_message.get("additional_info"): message["additionalInfo"] = raw_message.get("additional_info") logger.info( "Generated {} message of type METADATA_UPDATE from Maxwell " "logs {}".format( raw_message["event_type"], json.dumps( {"correlation_id": message["correlationId"], "message": message} ), ) ) return message def send_result_to_kafka(raw_messages): """Send generated messages to Kafka. Args: raw_messages (list): List of raw messages. Returns: bool: True if is success else False. """ logger.debug(f"Sending {len(raw_messages)} messages to Kafka") kafka_producer = kafka.get_kafka_producer() for raw_message in raw_messages: # sending messages synchronously for now message = enrich_message(raw_message) if not message: continue kafka_producer.send(config.KAFKA_TOPIC, message).get(timeout=10) logger.info( "Successfully sent message to switchboard".format(), ) logger.debug("Messages are sent") return True def handler(event, context): """Entrypoint to Lambda function. Args: event (optional): AWS Lambda event dependent structure with metadata. context (LambdaContext): AWS Lambda context. """ total_raw_events = [] graphql_switchboard_client = get_orchard_client() processed_messages_count = 0 for raw_record in deaggregator.iter_deaggregate_records(event["Records"]): processed_messages_count += 1 record_str = base64.b64decode(raw_record["kinesis"]["data"]) record_data = json.loads(record_str) record_raw_events = get_raw_events(record_data, graphql_switchboard_client) total_raw_events.extend(record_raw_events) logger.info(f"Processed messages count: {processed_messages_count}") logger.info(f"Total raw events: {len(total_raw_events)}") cleaned_raw_events = clean_raw_events(total_raw_events) logger.info(f"Total cleaned raw events: {len(cleaned_raw_events)}") send_result_to_kafka(cleaned_raw_events) return True