"""Snowflake unload tasks.""" from collections import namedtuple from copy import deepcopy import csv import json import re import time import uuid import boto from garcon import task from job import config as label from job import environment import smart_open config_map = dict(label=label) # Number of lines per file in S3. The number of lines is calculated based on # how many rows a lambda can send. For 128mb of memory, it's 40 lines/s. So for # 3 minutes, all we need is to multiply by 60 * 3. DynamoDB Throughput is # calculated with the number of line per seconds * 100 lambdas. LINES_PER_FILE = 40 * 60 * 3 DataPoint = namedtuple( 'DataPoint', [ 'metric', 'display_date', 'label_id', 'subaccount_id', 'activities', 'activities_paid']) def bootstrap(context, activity): """Bootstrap the load flow. The bootstrap figures out which configuration file needs to be used for this specific flow. Some parts of the configuration are customizable (such as the start date and end date). Args: activity (ActivityWorker): the activity worker. context (dict): the execution context. Context should contain 3 fields: config (name of the configuration to use for the flow), start_date and end_date. Raises: Exception: if a value is missing from the context, an error is thrown. """ activity.logger.info( 'Bootstrap the aggregate load job (from {start_date} to ' '{end_date})'.format( start_date=context.get('start_date', 'Missing!'), end_date=context.get('end_date', 'Missing!'))) if not context.get('config'): raise Exception('The config name is required for this flow to work.') if not context.get('start_date'): raise Exception('The starting date is missing from the flow context.') if not context.get('end_date'): raise Exception('The ending date is missing from the flow context.') config = deepcopy(config_map.get(context.get('config')).context) if not config: raise Exception( 'Config name ({config}) is not valid.'.format( config=context.get('config'))) sql_where_transaction = context.get('transaction_type_ids') if sql_where_transaction: sql_where_transaction = 'fa.transactiontypeid in ({})'.format( sql_where_transaction) else: sql_where_transaction = 'TRUE' fields = ( 'snowflake.query', 'snowflake.dest', 'dynamodb.table_name', 'dynamodb.dest') for field in fields: data = dict( env=environment.name, config=context.get('config'), start_date=context.get('start_date'), end_date=context.get('end_date')) if field == 'snowflake.query': data.update(sql_where_transaction=sql_where_transaction) if field == 'snowflake.dest': data.update(slug=str(uuid.uuid1())) config.update({field: config.get(field).format(**data)}) return config @task.decorate(timeout=1800) def generate_load_files( activity, source, destination): """Generate the files that contains the data to load to dynamodb. The unload files are streamed from s3 (they are in text format), we read them in series (since they are sorted). We combine all lines that share the same metric name and month into one line which is then written into a file on s3. Args: activity (ActivityWorker): the activity worker. source (str): the source of the data. destination (str): the destination of the processed data. """ activity.logger.info('Generating load files...') total_lines = 0 current_file = smart_open_file(destination, total_lines) current_metric = None current_month = None current_line = dict() for line in stream_content(source): parsed = next(csv.reader([line])) point = DataPoint(*parsed) month = point.display_date[0:7] day = point.display_date[8:] if (current_line and (current_metric != point.metric or current_month != month)): current_file.write(json.dumps(current_line) + '\n') total_lines += 1 if not total_lines % LINES_PER_FILE: current_file.close() current_file = smart_open_file(destination, total_lines) current_line = dict() current_metric = point.metric current_month = month current_line.update({ 'metric': current_metric, 'month': month, 'label_id': point.label_id, 'subaccount_id': point.subaccount_id, day: dict( all=int(point.activities), paid=int(point.activities_paid)) }) current_file.close() def smart_open_file(destination, total_lines): """Generate the file name. Args: destination (str): the destination of the file. total_lines (int): the number of lines. Returns: Reader: the smart open reader with write permissions. """ name = '{}{}.txt'.format(destination, int(total_lines / LINES_PER_FILE)) return smart_open.smart_open(name, 'wb') def stream_content(source): """Stream content. Args: source (str): the string that represents where the data is from. Yield: str: the content of the line. """ source = re.match('s3://(.+?)/(.*)$', source) bucket, prefix = source.groups() keys = boto.connect_s3().get_bucket(bucket).list(prefix=prefix) for key in keys: with smart_open.smart_open(key) as lines: for line in lines: line = line.decode('utf8').strip() if not line: continue yield line @task.decorate(timeout=3600) def wait_until_empty_s3_path(activity, s3_path): """Wait until a s3 path is empty. Args: activity (ActivityWorker): the activity worker. s3_path (str): the s3 path (e.g. s3://bucket/path/). """ source = re.match('s3://(.+?)/(.*)$', s3_path) bucket_name, prefix = source.groups() bucket = boto.connect_s3().get_bucket(bucket_name) while True: try: files = iter(bucket.list(prefix=prefix)) current = next(files) # S3 includes the parent path an object. If the prefix is the same # just checking against the next file. if current.name == prefix: next(files) activity.logger.info('Waiting until empty s3_path...') time.sleep(60) except StopIteration: break