"""Meta Daily Ingestion Workflow tasks.""" from datetime import datetime import re import boto3 from botocore.exceptions import ClientError as BotocoreClientError from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status from feed_ingestion.flows.meta_daily import config from feed_ingestion.tasks import s3_tasks from feed_ingestion.tasks import STOP_RESPONSE from feed_ingestion.tasks.overall_status_tasks import ( set_overall_status_enhanced, ) @task.decorate(timeout=1000) def bootstrap(activity, date, report, reload): """Bootstrap workflow by building the list of sources not yet ingested. Checks per-licensor DynamoDB status to determine which of the two source files (sme, theorchard) still need to be downloaded and loaded for the given date and report type. Args: activity (ActivityWorker): The activity worker. date (str): Reporting date (YYYY-MM-DD). report (str): Report type ('consumption' or 'production'). reload (str): 'True' to reset all statuses and re-process. Returns: dict: Workflow context including pending_sources list. """ date_obj = ( datetime.strptime(date, '%Y-%m-%d') if date else datetime.today() ) date_str = date_obj.strftime('%Y-%m-%d') date_compact = date_obj.strftime('%Y%m%d') report_feed_name = f'{config.feed_name}_{report}' if reload == 'True': activity.logger.info( f'Reload requested — deleting status for ' f'{report_feed_name} {date_str}' ) for source in config.reports[report]['sources']: garcon_feed_status.delete_status( f'{report_feed_name}_{source["licensor"]}', date_str ) garcon_feed_status.delete_status(report_feed_name, date_str) else: overall_status = garcon_feed_status.get_overall_status( report_feed_name, date_str ) if overall_status == garcon_feed_status.STATUS_INGESTED: activity.logger.info( f'Already fully ingested: {report_feed_name} {date_str}' ) return STOP_RESPONSE archive_path = config.s3['archive']['path'].format(date=date_str) pending_sources = [] for source in config.reports[report]['sources']: licensor = source['licensor'] licensor_feed = f'{report_feed_name}_{licensor}' licensor_status = garcon_feed_status.get_overall_status( licensor_feed, date_str ) if licensor_status == garcon_feed_status.STATUS_INGESTED: activity.logger.info( f'Skipping {licensor_feed} — already ingested' ) continue drop_path = source['drop_path'].format(date=date_compact) file_name = source['file_name'].format(date=date_compact) pending_sources.append( { 'licensor': licensor, 'licensor_feed': licensor_feed, 'source_bucket': config.sme_drop_bucket, 'source_key': f'{drop_path}/{file_name}', 'archive_key': f'{archive_path}/{file_name}', 'file_name': file_name, } ) return dict( date=date_str, report=report, report_feed_name=report_feed_name, staging_raw_table=config.reports[report]['staging_raw_table'], archive_s3_path=( f's3://{config.s3["archive"]["bucket"]}/{archive_path}/' ), pending_sources=pending_sources, ) @task.decorate(timeout=6000) def grab_available_files(activity, pending_sources): """Copy pending source files from SME S3 bucket to the archive bucket. Attempts to copy each pending file. Files not yet present in the SME bucket are skipped so that available files can be loaded immediately without waiting for the full set. Args: activity (ActivityWorker): The activity worker. pending_sources (list): Source dicts for files not yet ingested. Returns: dict: available_sources with successfully archived files, or stop=True when no files were found in the SME bucket. """ if not pending_sources: return dict(available_sources=[]) available_sources = [] for source in pending_sources: archive_bucket = config.s3['archive']['bucket'] if config.environment == 'dev': try: s3 = boto3.client('s3') s3.copy( CopySource={ 'Bucket': source['source_bucket'], 'Key': source['source_key'], }, Bucket=archive_bucket, Key=source['archive_key'], ) obj = s3.head_object( Bucket=archive_bucket, Key=source['archive_key'] ) found = True file_size = obj['ContentLength'] except BotocoreClientError as e: activity.logger.warning( f'File not available: s3://{source["source_bucket"]}/' f'{source["source_key"]}: {e}' ) found = False file_size = 0 else: result = s3_tasks.copy_file_from_sme_s3_to_theocrhard( activity, secrets_path=config.sme_secrets_path, source_bucket_name=source['source_bucket'], source_key_name=source['source_key'], destination_bucket_name=archive_bucket, destination_key_name=source['archive_key'], replace=True, ) found = bool(result.get(source['file_name'])) file_size = result.get('file_size', 0) if not found: activity.logger.warning( f'File not available: {source["source_key"]}' ) continue available_sources.append({**source, 'file_size': file_size}) activity.logger.info(f'Archived: {source["file_name"]}') if not available_sources: activity.logger.info( 'No files available from SME S3 for this run — will retry' ) return dict(stop=True, available_sources=[]) return dict(available_sources=available_sources) @task.decorate(timeout=7200) def load_staging_raw( activity, available_sources, staging_raw_table, date, report, archive_s3_path, ): """Load each available source file into the staging_raw table. Processes each source file independently: 1. Deletes existing rows for date + licensor (idempotent resume). 2. COPYs INTO staging_raw via a Snowflake external stage. 3. Marks the per-licensor DynamoDB status as INGESTED. Args: activity (ActivityWorker): The activity worker. available_sources (list): Source dicts with file_name, licensor, etc. staging_raw_table (str): Target Snowflake staging_raw table name. date (str): Reporting date (YYYY-MM-DD). report (str): Report type ('consumption' or 'production'). archive_s3_path (str): S3 URL of the archive directory. """ if not available_sources: return from feed_ingestion.flows.helpers import get_sf_config from feed_ingestion.flows.meta_daily.stage_loader import MetaDailySL from snowflake_connector.etl_connector import ( SnowflakeSQLExecutor, SQLLoader, ) sql_loader = SQLLoader(__file__) sf_config = get_sf_config(config.secrets_path) date_obj = datetime.strptime(date, '%Y-%m-%d') with SnowflakeSQLExecutor(sf_config) as executor: sl = MetaDailySL(executor, sql_loader) stage_name = f'meta_daily_{report}_{date_obj:%Y%m%d}' sl.create_stage(stage_name, archive_s3_path, aws={}) try: for source in available_sources: licensor = source['licensor'] licensor_feed = source['licensor_feed'] if ( garcon_feed_status.get_overall_status(licensor_feed, date) == garcon_feed_status.STATUS_INGESTED ): activity.logger.info( f'Already ingested: {licensor_feed} — skipping' ) continue date_match = re.search(r'(\d{8})\.txt$', source['file_name']) activity_date = ( date_match.group(1) if date_match else date_obj.strftime('%Y%m%d') ) sl.clean_staging_raw_table( staging_raw_table, activity_date, licensor=licensor ) sl.load_staging_raw_table( staging_raw_table, source_files_dict={ 'files': [ { 'file_name': source['file_name'], 'file_size': source['file_size'], 'found': True, } ] }, stage_name=stage_name, licensor=licensor, activity_date=activity_date, events_column=config.reports[report]['events_column'], ) set_overall_status_enhanced( licensor_feed, date, garcon_feed_status.STATUS_INGESTED, activity, ) activity.logger.info( f'Loaded {source["file_name"]} for {licensor}' ) finally: sl.drop_stage(stage_name) @task.decorate(timeout=600) def set_overall_status_if_complete(activity, report, report_feed_name, date): """Set overall feed status to INGESTED when all licensor sources are done. Checks the per-licensor DynamoDB statuses. Only marks the overall report_feed_name as INGESTED if every configured source has been ingested. Args: activity (ActivityWorker): The activity worker. report (str): Report type ('consumption' or 'production'). report_feed_name (str): Overall feed name for the report. date (str): Reporting date (YYYY-MM-DD). """ for source in config.reports[report]['sources']: licensor_feed = f'{report_feed_name}_{source["licensor"]}' status = garcon_feed_status.get_overall_status(licensor_feed, date) if status != garcon_feed_status.STATUS_INGESTED: activity.logger.info( f'Not yet fully ingested: {licensor_feed} ({status})' ) return set_overall_status_enhanced( report_feed_name, date, garcon_feed_status.STATUS_INGESTED, activity ) activity.logger.info( f'All sources ingested — set overall INGESTED: {report_feed_name}' )