import sys import abc from datetime import datetime, timedelta import time from pyspark.context import SparkContext # type: ignore from awsglue.utils import getResolvedOptions # type: ignore from awsglue.context import GlueContext # type: ignore from awsglue.job import Job # type: ignore from utils.snowflake.snowflake_client import get_snowflake_connection # type: ignore import boto3 # type: ignore DYNAMODB_DEFAULT_EXPORT_TIMEOUT = 600 class DynamoDBtoS3Job(abc.ABC): """ Abstract base class representing an AWS Glue Job that gets data from DynamoDB, exports it into an S3 bucket, and then calls a procedure to upsert it into Snowflake. """ def __init__( self, dynamodb_table_name: str, procedure_name: str, task_monitor_name: str, region_name: str = "us-east-2", dynamodb_export_timeout: int = DYNAMODB_DEFAULT_EXPORT_TIMEOUT, prod_warehouse: str = "SUNO_PROD_GLUE_HOURLY_X_SMALL", staging_warehouse: str = "SUNO_STAGING_GLUE_HOURLY_X_SMALL", print_debug_logs: bool = False, execute_snowflake_procedure: bool = True, ): self.dynamodb_table_name = dynamodb_table_name self.procedure_name = procedure_name self.task_monitor_name = task_monitor_name self.print_debug_logs = print_debug_logs self.prod_warehouse_name = prod_warehouse self.staging_warehouse_name = staging_warehouse ( self.account_id, self.environment, self.s3_bucket, self.warehouse, self.db_name, ) = self._get_running_info() self.region = region_name self.dynamodb_export_timeout = dynamodb_export_timeout self.start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.execute_snowflake_procedure = execute_snowflake_procedure def debug_print(self, message: str): if self.print_debug_logs: print(message) def _get_running_info(self): """ Gets important information about the job's running environment, depending on whether the job is run in staging or production. Returns the environment (staging or prod) and the S3 bucket, Snowflake warehouse, and Snowflake database to write to. """ PROD_ACCOUNT_ID = "734185074900" STAGING_ACCOUNT_ID = "590183763515" # uses the account id to determine whether we're in prod or staging sts = boto3.client("sts") account_id = sts.get_caller_identity()["Account"] if account_id == PROD_ACCOUNT_ID: environment = "PROD" s3_bucket = "analytics-database-data" warehouse = self.prod_warehouse_name elif account_id == STAGING_ACCOUNT_ID: environment = "STAGING" s3_bucket = "analytics-database-data-staging" warehouse = self.staging_warehouse_name else: raise Exception("Invalid account ID.") # sets the Snowflake database to write to depending on whether we're in prod or staging db_name = f"SUNO_{environment}" return account_id, environment, s3_bucket, warehouse, db_name def _initialize_glue_context(self): """ Initializes some context necessary for the Glue Job to be run. """ args = getResolvedOptions(sys.argv, ["JOB_NAME"]) sc = SparkContext() glueContext = GlueContext(sc) spark = glueContext.spark_session job = Job(glueContext) job.init(args["JOB_NAME"], args) return glueContext, spark, job def get_call_snowflake_procedure_sql( self, procedure_name: str, params: list, ): """ Returns a SQL query that calls a Snowflake procedure with the given parameters. """ return f"""call {procedure_name}({", ".join([f"'{param}'" for param in params])})""" @abc.abstractmethod def get_s3_save_location_path(self) -> str: """ Returns the path in S3 where the data from DynamoDB will be saved. """ pass @abc.abstractmethod def get_dynamodb_export_time_interval(self) -> tuple[datetime, datetime]: """ Returns the "from" and "to" times specifying a range of time, such that data updated or created within that time range will be exported from DynamoDB. """ pass @abc.abstractmethod def get_snowflake_procedure_sql_parameters(self) -> list[str]: """ Returns the parameters that should be passed to the Snowflake procedure that performs the upsert. """ pass @abc.abstractmethod def get_job_frequency(self) -> str: """ Returns the frequency with which this job is called, e.g. "DAILY" or "HOURLY". """ pass def _start_incremental_dynamodb_export_to_s3(self, client): """ Triggers an incremental export of the DynamoDB table to S3 using the given boto3 client. """ from_time, to_time = self.get_dynamodb_export_time_interval() export_description = client.export_table_to_point_in_time( TableArn=f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{self.dynamodb_table_name}", S3Bucket=f"{self.s3_bucket}", S3Prefix=f"{self.get_s3_save_location_path()}", ExportType="INCREMENTAL_EXPORT", IncrementalExportSpecification={ "ExportFromTime": from_time, "ExportToTime": to_time, }, )["ExportDescription"] return export_description["ExportArn"], export_description["ExportStatus"] def _poll_for_export_completion( self, client, export_arn, export_status, timeout=DYNAMODB_DEFAULT_EXPORT_TIMEOUT, frequency=30, ): """ Checks repeatedly until the DynamoDB to S3 export is complete using the given boto3 client. Throws an exception if the export takes longer than the timeout period, or if the export terminates with any status other than "COMPLETED". """ timeout_period = timedelta(seconds=timeout) export_start_time = datetime.now() timed_out = False while export_status == "IN_PROGRESS" and not timed_out: time.sleep(frequency) self.debug_print("Checking export status...") export_description = client.describe_export(ExportArn=export_arn)[ "ExportDescription" ] export_status = export_description["ExportStatus"] self.debug_print(f"Export status: {export_status}") if datetime.now() - export_start_time > timeout_period: timed_out = True break if timed_out: raise Exception(f"Export took longer than {timeout} seconds and timed out.") elif export_status == "COMPLETED": item_count = export_description["ItemCount"] self.debug_print(f"Exported table to S3. Item count: {item_count}") else: raise Exception(f"Export failed to complete. Status: {export_status}.") return item_count def run_job(self): """ Runs this Glue Job. Gets data from the DynamoDB table with the table name associated with this job, exports it to S3 using the boto3 client, and then calls a Snowflake procedure to upsert it. Does not handle any errors. """ # Glue parameters _, _, job = self._initialize_glue_context() self.debug_print(f"Environment: {self.environment}") self.debug_print(f"S3 Bucket: {self.s3_bucket}") self.debug_print(f"Warehouse: {self.warehouse}") self.debug_print(f"DB Name: {self.db_name}") client = boto3.client("dynamodb") self.debug_print("Exporting table to S3...") export_arn, export_status = self._start_incremental_dynamodb_export_to_s3( client ) item_count = self._poll_for_export_completion( client, export_arn, export_status, timeout=self.dynamodb_export_timeout ) if self.execute_snowflake_procedure: task_monitor_sql_params = [ self.task_monitor_name, self.start_time, item_count, "SUCCESS", self.get_job_frequency().upper(), ] snowflake_procedure_sql = self.get_call_snowflake_procedure_sql( self.procedure_name, self.get_snowflake_procedure_sql_parameters() ) self.debug_print(f"Calling SQL: {snowflake_procedure_sql}") task_monitor_sql = self.get_call_snowflake_procedure_sql( "TASK_MONITOR_INSERT_PROC", task_monitor_sql_params ) connection = get_snowflake_connection( self.warehouse, self.db_name, self.environment ) cursor = connection.cursor() self.debug_print(f"Calling SQL: {snowflake_procedure_sql}") result = cursor.execute(snowflake_procedure_sql) self.debug_print(f"Calling SQL: {task_monitor_sql}") result = cursor.execute(task_monitor_sql) self.debug_print("Query executed successfully.") job.commit() class HourlyDynamoDBtoS3Job(DynamoDBtoS3Job): """ A Glue Job that exports data from DynamoDB to S3 hourly. """ def __init__( self, dynamodb_table_name: str, procedure_name: str, task_monitor_name: str, region_name: str = "us-east-2", dynamodb_export_timeout: int = DYNAMODB_DEFAULT_EXPORT_TIMEOUT, prod_warehouse: str = "SUNO_PROD_GLUE_HOURLY_X_SMALL", staging_warehouse: str = "SUNO_STAGING_GLUE_HOURLY_X_SMALL", print_debug_logs: bool = False, execute_snowflake_procedure: bool = True, ): DynamoDBtoS3Job.__init__( self, dynamodb_table_name, procedure_name, task_monitor_name, region_name=region_name, dynamodb_export_timeout=dynamodb_export_timeout, prod_warehouse=prod_warehouse, staging_warehouse=staging_warehouse, print_debug_logs=print_debug_logs, execute_snowflake_procedure=execute_snowflake_procedure, ) current_date = datetime.now() one_hour_ago = current_date - timedelta(hours=1) self.start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.hour_start = one_hour_ago.replace(minute=0, second=0, microsecond=0) self.hour_end = self.hour_start + timedelta(hours=1) self.date = one_hour_ago.strftime("%Y-%m-%d") self.hour = one_hour_ago.strftime("%H") def get_s3_save_location_path(self) -> str: return f"{self.dynamodb_table_name}/pdate={self.date}/phour={self.hour}" def get_dynamodb_export_time_interval(self) -> tuple[datetime, datetime]: return self.hour_start, self.hour_end def get_snowflake_procedure_sql_parameters(self) -> list[str]: stage_path = ( f"@SUNO_DYNAMODB_EVENTS/{self.get_s3_save_location_path()}/AWSDynamoDB/data" ) return [self.date, self.hour, stage_path] def get_job_frequency(self) -> str: return "HOURLY"