"""Tasks for Ingestion Workflow.""" import datetime import os import re from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status from feed_ingestion.conf.config import merge_configs from feed_ingestion.flows.helpers import get_sf_config from feed_ingestion.flows.setlive import config from feed_ingestion.flows.setlive.snowflake_executor \ import SetliveSnowflakeExecutor from feed_ingestion.tasks.feed_status_tasks \ import check_files_on_s3 as common_check_files_on_s3 from feed_ingestion.util import task_status def _extract_file_list(source_files_dict): """Return a normalized list of file names from source_files_dict.""" files = source_files_dict.get('files', []) if not files: return [] if isinstance(files[0], dict): return [file_info['file_name'] for file_info in files] return list(files) def _build_file_patterns(file_names, fallback_pattern): """Build exact-match patterns for stage copy and table filtering.""" normalized_files = [ file_name for file_name in file_names if isinstance(file_name, str) ] if not normalized_files: return fallback_pattern, fallback_pattern escaped_files = [re.escape(file_name) for file_name in normalized_files] if len(escaped_files) == 1: stage_pattern = rf'.*{escaped_files[0]}$' table_pattern = rf'^{escaped_files[0]}$' else: joined = '|'.join(escaped_files) stage_pattern = rf'.*({joined})$' table_pattern = rf'^({joined})$' return stage_pattern, table_pattern def _debug_enabled(): """Return whether local debug logging is enabled for setlive flow.""" return os.environ.get('SETLIVE_LOCAL_DEBUG', '').lower() in ( '1', 'true', 'yes', 'on' ) @task.decorate(timeout=700) def bootstrap(activity, date, report, reload): """Bootstrap workflow by getting the correct configurations. Args: activity (ActivityWorker): The activity worker. date (str): Reporting date (YYYY-MM-DD). report (str): one of config.reports. reload (str or None): If 'True' reload status. Returns: dict: Context. """ assert report in config.reports report_config = config.reports[report] feed_name = f'{config.feed_name}_{report}' date_obj = datetime.datetime.strptime(date, '%Y-%m-%d').date() date = date_obj.strftime('%Y-%m-%d') if reload == 'True': activity.logger.info('Delete status for feed: {} {} '.format( feed_name, date)) garcon_feed_status.delete_status(feed_name, date) activity.logger.info('Bootstrap flow for {} date: {}'.format(report, date)) s3_dir_path = f's3://{config.drop_bucket}/{config.drop_path}' filename = report_config['filename_template'].format(date=date_obj) file_pattern = rf'.*\/{filename}' return dict( feed_name=feed_name, date=date, report_name=report, secrets_path=config.secrets_path, drop_bucket=config.drop_bucket, drop_path=config.drop_path, s3_dir_path=s3_dir_path, file_pattern=file_pattern, snowflake_pattern=filename, staging_raw_table=report_config['staging_raw_table'], staging_raw_table_errors=report_config['staging_raw_table_errors'], pii_columns=report_config['pii_columns'], ) @task.decorate(timeout=700) def check_files_on_s3( activity, feed_name, date, s3_download_path, file_pattern, list_output=False): """Wrap feed_status_task to exclude a list of files due to size limit.""" result_dict = common_check_files_on_s3( activity, feed_name, date, s3_download_path, file_pattern, True ) if 'stop' in result_dict: return result_dict else: matched_files = _extract_file_list( result_dict.get('source_files_dict', {}) ) ingested_files = task_status.get_values( feed_name, date, 'ingested_files_status' ) new_files = [ file_name for file_name in matched_files if file_name not in ingested_files ] if not new_files: return {'stop': True} if _debug_enabled(): activity.logger.info( '[setlive-debug] matched files on s3 path %s pattern %s: %s', s3_download_path, file_pattern, matched_files, ) activity.logger.info( '[setlive-debug] new files for %s %s: %s', feed_name, date, new_files, ) return { 'source_files_dict': { 'files': new_files } } @task.decorate(timeout=700) def mark_ingested_files( activity, feed_name, date, s3_download_path, file_pattern): """Persist the current matching file list for this date. This allows subsequent runs for the same date to process only newly arrived files instead of short-circuiting on overall date status. """ result_dict = common_check_files_on_s3( activity, feed_name, date, s3_download_path, file_pattern, True, ) files = result_dict.get('source_files_dict', {}).get('files', []) if not files: activity.logger.info( 'No files found to mark as ingested for %s %s', feed_name, date, ) return task_status.set_values(feed_name, date, 'ingested_files_status', files) task_status.set_values(feed_name, date, 'ingested_files', files) activity.logger.info( 'Marked %s ingested file(s) for %s %s', len(files), feed_name, date, ) @task.decorate(timeout=3600 * 5) def load_staging_raw_table_reports( activity, feed_name, date, report_name, s3_dir_path, staging_raw_table_name, staging_raw_errors_table_name, file_names, snowflake_pattern, pii_columns, ): """Save data to staging raw table(s) for given report. Args: activity (ActivityWorker): The Garcon activity worker. feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). report_name (str): Reporting name. s3_dir_path (str): S3 directory. staging_raw_table_name (str): The staging table name for report. staging_raw_errors_table_name (str): The staging errors table name. file_names (dict[str, list[str]]): File names to process (excluded). snowflake_pattern (str): Snowflake pattern for file names. pii_columns (list[str]): List of pii column names. """ temp_table_name = 'temp_{staging_raw_table_name}_{date}'.format( staging_raw_table_name=staging_raw_table_name, date=date.replace('-', '')) sf_config = merge_configs(get_sf_config(config.secrets_path), {}) exact_file_names = _extract_file_list(file_names) stage_file_pattern, table_file_pattern = _build_file_patterns( exact_file_names, snowflake_pattern, ) def _fetch_count(executor, table_name, where_clause='', **kwargs): """Return count(*) for a table with optional where clause.""" sql = ( 'SELECT COUNT(*) FROM %(db)i.%(schema)i.%(table_name)i ' f'{where_clause}' ) params = dict( db=executor.sf_config['db'], schema=executor.sf_config['schema'], table_name=table_name, **kwargs, ) sql, non_identifier_params = executor.validator.format_identifiers( sql, params ) return executor.fetchone(sql, params=non_identifier_params)[0] with SetliveSnowflakeExecutor(sf_config) as executor: debug_enabled = _debug_enabled() executor.create_temp_staging_raw_table( report_name=report_name, temp_staging_raw_table=temp_table_name ) activity.logger.info(f'{temp_table_name} was created') executor.assign_masking_on_temp_staging_raw_table( temp_staging_raw_table=temp_table_name, pii_columns=pii_columns ) activity.logger.info(f'{temp_table_name} default masking was set') stage_name = f"stage_setlive_{date.replace('-', '')}" executor.create_stage( s3_dir_path=s3_dir_path, stage_name=stage_name ) activity.logger.info(f'{stage_name} was created') if debug_enabled: activity.logger.info( '[setlive-debug] stage path=%s stage_pattern=%s files=%s', s3_dir_path, stage_file_pattern, exact_file_names, ) executor.load_temp_staging_raw_table( temp_staging_raw_table=temp_table_name, file_name=stage_file_pattern, stage_name=stage_name, ) activity.logger.info( f'{temp_table_name} was loaded for pattern {stage_file_pattern}') if debug_enabled: temp_count = _fetch_count( executor, temp_table_name, where_clause='WHERE SOURCE_FILENAME RLIKE %(file_name)s', file_name=table_file_pattern, ) activity.logger.info( '[setlive-debug] temp rows matching pattern=%s: %s', table_file_pattern, temp_count, ) executor.clean_staging_raw_table( staging_raw_table=staging_raw_table_name, staging_raw_errors_table=staging_raw_errors_table_name, file_name=table_file_pattern, date=date, ) activity.logger.info( f'{staging_raw_table_name} and {staging_raw_errors_table_name} ' f'were cleared for pattern {table_file_pattern}' ) executor.load_staging_raw_table( staging_raw_table=staging_raw_table_name, staging_raw_errors_table=staging_raw_errors_table_name, temp_staging_raw_table=temp_table_name, report_name=report_name, date=date, file_name=table_file_pattern, ) activity.logger.info( f'{staging_raw_table_name} and {staging_raw_errors_table_name} ' f'were loaded by pattern {table_file_pattern}' ) if debug_enabled: raw_count = _fetch_count( executor, staging_raw_table_name, where_clause=( 'WHERE REPORT_DATE = %(date)s ' 'AND REPORT_FILENAME RLIKE %(file_name)s' ), date=date, file_name=table_file_pattern, ) err_count = _fetch_count( executor, staging_raw_errors_table_name, where_clause=( 'WHERE REPORT_DATE = %(date)s ' 'AND REPORT_FILENAME RLIKE %(file_name)s' ), date=date, file_name=table_file_pattern, ) activity.logger.info( '[setlive-debug] final row counts raw=%s errors=%s ' 'for date=%s pattern=%s', raw_count, err_count, date, table_file_pattern, ) executor.drop_table(table=temp_table_name) activity.logger.info(f'{temp_table_name} was dropped for {date}') executor.drop_stage(stage_name=stage_name) activity.logger.info(f'{stage_name} stage was dropped')