From 751c84cd098a3b78ef8e3edb2f27629d9edfc412 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:02:10 -0300 Subject: [PATCH 01/75] Submitted python model successfully --- dbt/adapters/athena/connections.py | 11 ++ dbt/adapters/athena/impl.py | 19 ++- dbt/adapters/athena/python_submissions.py | 74 +++++++++ .../models/incremental/incremental.sql | 15 +- .../models/table/create_table_as.sql | 140 +++++++++++++----- .../materializations/models/table/table.sql | 7 +- 6 files changed, 217 insertions(+), 49 deletions(-) create mode 100644 dbt/adapters/athena/python_submissions.py diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index fb7d5f1b..3b6c3fc1 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -47,6 +47,7 @@ class AthenaCredentials(Credentials): num_retries: Optional[int] = 5 s3_data_dir: Optional[str] = None s3_data_naming: Optional[str] = "schema_table_unique" + spark_work_group: Optional[str] = None lf_tags: Optional[Dict[str, str]] = None @property @@ -70,8 +71,18 @@ def _connection_keys(self) -> Tuple[str, ...]: "s3_data_dir", "s3_data_naming", "lf_tags", + "spark_work_group", ) + def get_region_name(self) -> str: + return self.region_name + + def get_profile_name(self) -> str: + return self.aws_profile_name + + def get_spark_work_group(self) -> str: + return self.spark_work_group + class AthenaCursor(Cursor): def __init__(self, **kwargs): diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index f13d38de..467a621e 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -1,7 +1,7 @@ import posixpath as path from itertools import chain from threading import Lock -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, Type from urllib.parse import urlparse from uuid import uuid4 @@ -10,11 +10,13 @@ from dbt.adapters.athena import AthenaConnectionManager from dbt.adapters.athena.config import get_boto3_config +from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper from dbt.adapters.athena.relation import AthenaRelation, AthenaSchemaSearchMap from dbt.adapters.athena.utils import clean_sql_comment -from dbt.adapters.base import Column, available +from dbt.adapters.base import PythonJobHelper, Column, available from dbt.adapters.base.relation import BaseRelation, InformationSchema from dbt.adapters.sql import SQLAdapter +from dbt.contracts.connection import AdapterResponse from dbt.contracts.graph.manifest import Manifest from dbt.contracts.graph.nodes import CompiledNode from dbt.events import AdapterLogger @@ -617,6 +619,19 @@ def persist_docs_to_glue( glue_client.update_table(DatabaseName=relation.schema, TableInput=updated_table) + def generate_python_submission_response(self, submission_result: Any) -> AdapterResponse: + if submission_result is not None: + return AdapterResponse(_message="OK") + return AdapterResponse(_message="ERROR") + + @property + def default_python_submission_method(self) -> str: + return "athena_helper" + + @property + def python_submission_helpers(self) -> Dict[str, Type[PythonJobHelper]]: + return {"athena_helper": AthenaPythonJobHelper} + @available def list_schemas(self, database: str) -> List[str]: conn = self.connections.get_thread_connection() diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py new file mode 100644 index 00000000..b1e7b4ee --- /dev/null +++ b/dbt/adapters/athena/python_submissions.py @@ -0,0 +1,74 @@ +from functools import cached_property +from typing import Any, Dict + +import boto3 + +from dbt.adapters.athena.connections import AthenaCredentials +from dbt.adapters.base import PythonJobHelper + +DEFAULT_POLLING_INTERVAL = 10 +SUBMISSION_LANGUAGE = "python" +DEFAULT_TIMEOUT = 60 * 60 * 24 + + +class AthenaPythonJobHelper(PythonJobHelper): + def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: + self.identifier = parsed_model["alias"] + self.schema = parsed_model["schema"] + self.parsed_model = parsed_model + self.timeout = self.get_timeout() + self.polling_interval = DEFAULT_POLLING_INTERVAL + self.region_name = credentials.get_region_name() + self.profile_name = credentials.get_profile_name() + self.spark_work_group = credentials.get_spark_work_group() + + @cached_property + def session_id(self) -> str: + if self._list_sessions() is None: + return self._start_session().get("SessionId") + return self._list_sessions().get("SessionId") + + @cached_property + def athena_client(self) -> Any: + return boto3.client("athena") + + def get_timeout(self) -> int: + timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) + if timeout <= 0: + raise ValueError("Timeout must be a positive integer") + return timeout + + def _list_sessions(self) -> dict: + try: + response = self.athena_client.list_sessions( + WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE" + ) + return response.get("Sessions")[0] + except Exception: + return None + + def _start_session(self) -> dict: + try: + response = self.athena_client.start_session( + WorkGroup=self.spark_work_group, + EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 1, "DefaultExecutorDpuSize": 18}, + ) + return response + except Exception: + return None + + def submit(self, compiled_code: str) -> dict: + try: + response = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.strip() + ) + return response + except Exception: + return None + + def _terminate_session(self) -> dict: + try: + response = self.athena_client.terminate_session(SessionId=self.session_id) + return response + except Exception: + return None diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index 865af83d..c2400c15 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -1,4 +1,5 @@ -{% materialization incremental, adapter='athena' -%} +{% materialization incremental, adapter='athena', supported_languages=['sql', 'python'] -%} + {%- set language = model['language'] -%} {% set raw_strategy = config.get('incremental_strategy') or 'insert_overwrite' %} {% set table_type = config.get('table_type', default='hive') | lower %} @@ -24,16 +25,16 @@ {% set to_drop = [] %} {% if existing_relation is none %} - {% set build_sql = create_table_as(False, target_relation, sql) -%} + {% set build_sql = create_table_as(False, target_relation, sql, language) -%} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} - {% set build_sql = create_table_as(False, target_relation, sql) -%} + {% set build_sql = create_table_as(False, target_relation, sql, language) -%} {% elif partitioned_by is not none and strategy == 'insert_overwrite' %} {% set tmp_relation = make_temp_relation(target_relation) %} {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql)) %} + {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} {% do delete_overlapping_partitions(target_relation, tmp_relation, partitioned_by) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} @@ -42,7 +43,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql)) %} + {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% elif strategy == 'merge' and table_type == 'iceberg' %} @@ -58,12 +59,12 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql)) %} + {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} {% set build_sql = iceberg_merge(on_schema_change, tmp_relation, target_relation, unique_key, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% endif %} - {% call statement("main") %} + {% call statement("main", language=language) %} {{ build_sql }} {% endcall %} diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 49b3ce4e..4f42cb51 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -1,49 +1,57 @@ -{% macro athena__create_table_as(temporary, relation, sql) -%} - {%- set materialized = config.get('materialized', default='table') -%} - {%- set external_location = config.get('external_location', default=none) -%} - {%- set partitioned_by = config.get('partitioned_by', default=none) -%} - {%- set bucketed_by = config.get('bucketed_by', default=none) -%} - {%- set bucket_count = config.get('bucket_count', default=none) -%} - {%- set field_delimiter = config.get('field_delimiter', default=none) -%} - {%- set table_type = config.get('table_type', default='hive') | lower -%} - {%- set format = config.get('format', default='parquet') -%} - {%- set write_compression = config.get('write_compression', default=none) -%} - {%- set s3_data_dir = config.get('s3_data_dir', default=target.s3_data_dir) -%} - {%- set s3_data_naming = config.get('s3_data_naming', default=target.s3_data_naming) -%} - {%- set extra_table_properties = config.get('table_properties', default=none) -%} +{% macro athena__create_table_as(temporary, relation, compiled_code, language='sql') -%} + {%- if language == 'sql' -%} + {%- set materialized = config.get('materialized', default='table') -%} + {%- set external_location = config.get('external_location', default=none) -%} + {%- set partitioned_by = config.get('partitioned_by', default=none) -%} + {%- set bucketed_by = config.get('bucketed_by', default=none) -%} + {%- set bucket_count = config.get('bucket_count', default=none) -%} + {%- set field_delimiter = config.get('field_delimiter', default=none) -%} + {%- set table_type = config.get('table_type', default='hive') | lower -%} + {%- set format = config.get('format', default='parquet') -%} + {%- set write_compression = config.get('write_compression', default=none) -%} + {%- set s3_data_dir = config.get('s3_data_dir', default=target.s3_data_dir) -%} + {%- set s3_data_naming = config.get('s3_data_naming', default=target.s3_data_naming) -%} + {%- set extra_table_properties = config.get('table_properties', default=none) -%} +<<<<<<< HEAD {%- set location_property = 'external_location' -%} {%- set partition_property = 'partitioned_by' -%} {%- set work_group_output_location = adapter.get_work_group_output_location() -%} {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} +======= + {%- set location_property = 'external_location' -%} + {%- set partition_property = 'partitioned_by' -%} + {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} +>>>>>>> 10dd892 (Submitted python model successfully) - {%- if materialized == 'table_hive_ha' -%} - {%- set location = location.replace('__ha', '') -%} - {%- endif %} + {%- if materialized == 'table_hive_ha' -%} + {%- set location = location.replace('__ha', '') -%} + {%- endif %} - {%- if table_type == 'iceberg' -%} - {%- set location_property = 'location' -%} - {%- set partition_property = 'partitioning' -%} - {%- if bucketed_by is not none or bucket_count is not none -%} - {%- set ignored_bucket_iceberg -%} - bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function - when partitioning. Will be ignored - {%- endset -%} - {%- set bucketed_by = none -%} - {%- set bucket_count = none -%} - {% do log(ignored_bucket_iceberg) %} - {%- endif -%} - {%- if s3_data_naming in ['table', 'table_schema'] or external_location is not none -%} - {%- set error_unique_location_iceberg -%} - You need to have an unique table location when creating Iceberg table. Right now we are building tables in - a destructive way but in the near future we will be using the RENAME feature to provide near-zero downtime. - {%- endset -%} - {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} - {%- endif -%} - {%- endif %} + {%- if table_type == 'iceberg' -%} + {%- set location_property = 'location' -%} + {%- set partition_property = 'partitioning' -%} + {%- if bucketed_by is not none or bucket_count is not none -%} + {%- set ignored_bucket_iceberg -%} + bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function + when partitioning. Will be ignored + {%- endset -%} + {%- set bucketed_by = none -%} + {%- set bucket_count = none -%} + {% do log(ignored_bucket_iceberg) %} + {%- endif -%} + {%- if s3_data_naming in ['table', 'table_schema'] or external_location is not none -%} + {%- set error_unique_location_iceberg -%} + You need to have an unique table location when creating Iceberg table. Right now we are building tables in + a destructive way but in the near future we will be using the RENAME feature to provide near-zero downtime. + {%- endset -%} + {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} + {%- endif -%} + {%- endif %} - {% do adapter.delete_from_s3(location) %} + {% do adapter.delete_from_s3(location) %} +<<<<<<< HEAD create table {{ relation }} with ( table_type='{{ table_type }}', @@ -77,3 +85,61 @@ as {{ sql }} {% endmacro %} +======= + create table {{ relation }} + with ( + table_type='{{ table_type }}', + is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, + {{ location_property }}='{{ location }}', + {%- if partitioned_by is not none %} + {{ partition_property }}=ARRAY{{ partitioned_by | tojson | replace('\"', '\'') }}, + {%- endif %} + {%- if bucketed_by is not none %} + bucketed_by=ARRAY{{ bucketed_by | tojson | replace('\"', '\'') }}, + {%- endif %} + {%- if bucket_count is not none %} + bucket_count={{ bucket_count }}, + {%- endif %} + {%- if field_delimiter is not none %} + field_delimiter='{{ field_delimiter }}', + {%- endif %} + {%- if write_compression is not none %} + write_compression='{{ write_compression }}', + {%- endif %} + format='{{ format }}' + {%- if extra_table_properties is not none -%} + {%- for prop_name, prop_value in extra_table_properties.items() -%} + , + {{ prop_name }}={{ prop_value }} + {%- endfor -%} + {% endif %} + ) + as + {{ compiled_code }} + {%- elif language == 'python' -%} + {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) }} + {%- else -%} + {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} + {%- endif -%} +{%- endmacro -%} + +{%- macro athena__py_create_table_as(compiled_code, target_relation, temporary) -%} +{{ compiled_code }} +def materialize(session, df, target_relation): + # make sure pandas exists + import importlib.util + package_name = 'pandas' + if importlib.util.find_spec(package_name): + import pandas + if isinstance(df, pandas.core.frame.DataFrame): + # session.write_pandas does not have overwrite function + df = session.createDataFrame(df) + df.write.mode("overwrite").save_as_table('{{ target_relation.identifier }}', create_temp_table={{temporary}}) + +def main(session): + dbt = dbtObj(session.table) + df = model(dbt, session) + materialize(session, df, dbt.this) + return "OK" +{%- endmacro -%} +>>>>>>> 10dd892 (Submitted python model successfully) diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index ce995e99..34646220 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -1,5 +1,6 @@ -{% materialization table, adapter='athena' -%} +{% materialization table, adapter='athena', supported_languages=['sql', 'python'] -%} {%- set identifier = model['alias'] -%} + {%- set language = model['language'] -%} {%- set lf_tags = config.get('lf_tags', default=none) -%} {%- set lf_tags_columns = config.get('lf_tags_columns', default=none) -%} @@ -18,8 +19,8 @@ {%- endif -%} -- build model - {% call statement('main') -%} - {{ create_table_as(False, target_relation, sql) }} + {% call statement('main', language=language) -%} + {{ create_table_as(False, target_relation, compiled_code, language) }} {%- endcall %} {% if table_type != 'iceberg' %} From 773700077854a3c4631afb262d4c81f4e1d62648 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sat, 25 Mar 2023 15:47:01 -0300 Subject: [PATCH 02/75] Rebase and resolved conflicts --- .../models/table/create_table_as.sql | 44 +------------------ 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 4f42cb51..f7a96997 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -13,16 +13,10 @@ {%- set s3_data_naming = config.get('s3_data_naming', default=target.s3_data_naming) -%} {%- set extra_table_properties = config.get('table_properties', default=none) -%} -<<<<<<< HEAD - {%- set location_property = 'external_location' -%} - {%- set partition_property = 'partitioned_by' -%} - {%- set work_group_output_location = adapter.get_work_group_output_location() -%} - {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} -======= {%- set location_property = 'external_location' -%} {%- set partition_property = 'partitioned_by' -%} + {%- set work_group_output_location = adapter.get_work_group_output_location() -%} {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} ->>>>>>> 10dd892 (Submitted python model successfully) {%- if materialized == 'table_hive_ha' -%} {%- set location = location.replace('__ha', '') -%} @@ -51,41 +45,6 @@ {% do adapter.delete_from_s3(location) %} -<<<<<<< HEAD - create table {{ relation }} - with ( - table_type='{{ table_type }}', - is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, - {%- if work_group_output_location is none -%} - {{ location_property }}='{{ location }}', - {%- endif %} - {%- if partitioned_by is not none %} - {{ partition_property }}=ARRAY{{ partitioned_by | tojson | replace('\"', '\'') }}, - {%- endif %} - {%- if bucketed_by is not none %} - bucketed_by=ARRAY{{ bucketed_by | tojson | replace('\"', '\'') }}, - {%- endif %} - {%- if bucket_count is not none %} - bucket_count={{ bucket_count }}, - {%- endif %} - {%- if field_delimiter is not none %} - field_delimiter='{{ field_delimiter }}', - {%- endif %} - {%- if write_compression is not none %} - write_compression='{{ write_compression }}', - {%- endif %} - format='{{ format }}' - {%- if extra_table_properties is not none -%} - {%- for prop_name, prop_value in extra_table_properties.items() -%} - , - {{ prop_name }}={{ prop_value }} - {%- endfor -%} - {% endif %} - ) - as - {{ sql }} -{% endmacro %} -======= create table {{ relation }} with ( table_type='{{ table_type }}', @@ -142,4 +101,3 @@ def main(session): materialize(session, df, dbt.this) return "OK" {%- endmacro -%} ->>>>>>> 10dd892 (Submitted python model successfully) From c1d1ab24e93f7a029b03bc9bf04ad13387df6d95 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 26 Mar 2023 01:17:03 -0300 Subject: [PATCH 03/75] Execution successful but table not saved --- dbt/adapters/athena/impl.py | 10 +-- dbt/adapters/athena/python_submissions.py | 73 ++++++++++++++++--- .../models/table/create_table_as.sql | 26 ++++--- .../materializations/models/table/table.sql | 2 +- 4 files changed, 81 insertions(+), 30 deletions(-) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index 467a621e..330077f6 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -1,7 +1,7 @@ import posixpath as path from itertools import chain from threading import Lock -from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union, Type +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type, Union from urllib.parse import urlparse from uuid import uuid4 @@ -13,7 +13,7 @@ from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper from dbt.adapters.athena.relation import AthenaRelation, AthenaSchemaSearchMap from dbt.adapters.athena.utils import clean_sql_comment -from dbt.adapters.base import PythonJobHelper, Column, available +from dbt.adapters.base import Column, PythonJobHelper, available from dbt.adapters.base.relation import BaseRelation, InformationSchema from dbt.adapters.sql import SQLAdapter from dbt.contracts.connection import AdapterResponse @@ -620,9 +620,9 @@ def persist_docs_to_glue( glue_client.update_table(DatabaseName=relation.schema, TableInput=updated_table) def generate_python_submission_response(self, submission_result: Any) -> AdapterResponse: - if submission_result is not None: - return AdapterResponse(_message="OK") - return AdapterResponse(_message="ERROR") + if submission_result is None: + return AdapterResponse(_message="ERROR") + return AdapterResponse(_message="OK") @property def default_python_submission_method(self) -> str: diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index b1e7b4ee..8afc8f0f 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -1,3 +1,4 @@ +import time from functools import cached_property from typing import Any, Dict @@ -5,11 +6,15 @@ from dbt.adapters.athena.connections import AthenaCredentials from dbt.adapters.base import PythonJobHelper +from dbt.events import AdapterLogger +from dbt.exceptions import DbtRuntimeError -DEFAULT_POLLING_INTERVAL = 10 +DEFAULT_POLLING_INTERVAL = 2 SUBMISSION_LANGUAGE = "python" DEFAULT_TIMEOUT = 60 * 60 * 24 +logger = AdapterLogger("Athena") + class AthenaPythonJobHelper(PythonJobHelper): def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: @@ -43,32 +48,76 @@ def _list_sessions(self) -> dict: response = self.athena_client.list_sessions( WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE" ) + if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: + return None return response.get("Sessions")[0] except Exception: - return None + raise def _start_session(self) -> dict: try: response = self.athena_client.start_session( WorkGroup=self.spark_work_group, - EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 1, "DefaultExecutorDpuSize": 18}, + EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, ) + if response["State"] != "IDLE": + self._poll_until_session_creation(response["SessionId"]) return response except Exception: - return None + raise def submit(self, compiled_code: str) -> dict: try: - response = self.athena_client.start_calculation_execution( - SessionId=self.session_id, CodeBlock=compiled_code.strip() - ) - return response + calculation_execution_id = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.lstrip() + )["CalculationExecutionId"] + logger.debug(f"Submitted calculation execution id {calculation_execution_id}") + execution_status = self._poll_until_execution_completion(calculation_execution_id) + logger.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + result_s3_uri = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"]["ResultS3Uri"] + return result_s3_uri + else: + raise DbtRuntimeError(f"python model run ended in state {execution_status}") except Exception: - return None + raise def _terminate_session(self) -> dict: try: - response = self.athena_client.terminate_session(SessionId=self.session_id) - return response + self.athena_client.terminate_session(SessionId=self.session_id) except Exception: - return None + raise + + def _poll_until_execution_completion(self, calculation_execution_id): + polling_interval = self.polling_interval + while True: + execution_status = self.athena_client.get_calculation_execution_status( + CalculationExecutionId=calculation_execution_id + )["Status"]["State"] + if execution_status in ["COMPLETED", "FAILED", "CANCELLED"]: + return execution_status + time.sleep(polling_interval) + polling_interval *= 2 + if polling_interval > self.timeout: + raise DbtRuntimeError( + f"Execution {calculation_execution_id} did not complete within {self.timeout} seconds." + ) + + def _poll_until_session_creation(self, session_id): + polling_interval = self.polling_interval + while True: + creation_status = self.athena_client.get_session_status(SessionId=session_id)["Status"]["State"] + if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: + raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") + elif creation_status == "IDLE": + return creation_status + time.sleep(polling_interval) + polling_interval *= 2 + if polling_interval > self.timeout: + raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") + + def __del__(self) -> None: + logger.debug(f"Terminating session: {self.session_id}") + self._terminate_session() diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index f7a96997..978b4c92 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -51,10 +51,10 @@ is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, {{ location_property }}='{{ location }}', {%- if partitioned_by is not none %} - {{ partition_property }}=ARRAY{{ partitioned_by | tojson | replace('\"', '\'') }}, + {{ partition_property }}=ARRAY{{ partitioned_by | join("', '") | replace('"', "'") | prepend("'") | append("'") }}, {%- endif %} {%- if bucketed_by is not none %} - bucketed_by=ARRAY{{ bucketed_by | tojson | replace('\"', '\'') }}, + bucketed_by=ARRAY{{ bucketed_by | join("', '") | replace('"', "'") | prepend("'") | append("'") }}, {%- endif %} {%- if bucket_count is not none %} bucket_count={{ bucket_count }}, @@ -76,24 +76,26 @@ as {{ compiled_code }} {%- elif language == 'python' -%} - {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) }} + {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) | trim }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} {%- endmacro -%} {%- macro athena__py_create_table_as(compiled_code, target_relation, temporary) -%} -{{ compiled_code }} +{{ compiled_code | trim }} def materialize(session, df, target_relation): - # make sure pandas exists - import importlib.util - package_name = 'pandas' - if importlib.util.find_spec(package_name): - import pandas + import pandas + try: if isinstance(df, pandas.core.frame.DataFrame): - # session.write_pandas does not have overwrite function - df = session.createDataFrame(df) - df.write.mode("overwrite").save_as_table('{{ target_relation.identifier }}', create_temp_table={{temporary}}) + df = spark.createDataFrame(df) + df.write.saveAsTable( + name="{{ target_relation.schema}}.{{ target_relation.identifier }}", + format="parquet", + mode="overwrite" + ) + except Exception: + raise def main(session): dbt = dbtObj(session.table) diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index 34646220..79c7ba9c 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -23,7 +23,7 @@ {{ create_table_as(False, target_relation, compiled_code, language) }} {%- endcall %} - {% if table_type != 'iceberg' %} + {% if table_type != 'iceberg' and language != 'python' %} {{ set_table_classification(target_relation) }} {% endif %} From fd6a135e7be4a3edf49000c7b3a38ebe9a3f392c Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:02:12 -0300 Subject: [PATCH 04/75] Add incremental model support --- .../models/incremental/incremental.sql | 10 +++++----- .../models/table/create_table_as.sql | 19 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index c2400c15..6a6acb45 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -25,16 +25,16 @@ {% set to_drop = [] %} {% if existing_relation is none %} - {% set build_sql = create_table_as(False, target_relation, sql, language) -%} + {% set build_sql = create_table_as(False, target_relation, compiled_code, language) -%} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} - {% set build_sql = create_table_as(False, target_relation, sql, language) -%} + {% set build_sql = create_table_as(False, target_relation, compiled_code, language) -%} {% elif partitioned_by is not none and strategy == 'insert_overwrite' %} {% set tmp_relation = make_temp_relation(target_relation) %} {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} {% do delete_overlapping_partitions(target_relation, tmp_relation, partitioned_by) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} @@ -43,7 +43,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% elif strategy == 'merge' and table_type == 'iceberg' %} @@ -59,7 +59,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, sql, language)) %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} {% set build_sql = iceberg_merge(on_schema_change, tmp_relation, target_relation, unique_key, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% endif %} diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 978b4c92..cf58d47c 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -76,30 +76,29 @@ as {{ compiled_code }} {%- elif language == 'python' -%} - {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) | trim }} + {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} {%- endmacro -%} {%- macro athena__py_create_table_as(compiled_code, target_relation, temporary) -%} -{{ compiled_code | trim }} -def materialize(session, df, target_relation): +{{ compiled_code }} +def materialize(spark_session, df, target_relation): import pandas try: if isinstance(df, pandas.core.frame.DataFrame): - df = spark.createDataFrame(df) + df = spark_session.createDataFrame(df) df.write.saveAsTable( name="{{ target_relation.schema}}.{{ target_relation.identifier }}", format="parquet", - mode="overwrite" + mode="overwrite", ) + return "OK" except Exception: raise -def main(session): - dbt = dbtObj(session.table) - df = model(dbt, session) - materialize(session, df, dbt.this) - return "OK" +dbt = dbtObj(spark.table) +df = model(dbt, spark) +materialize(spark, df, dbt.this) {%- endmacro -%} From 7913646da68b3ed5c6efffc68edf161021187962 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 3 Apr 2023 20:47:24 -0300 Subject: [PATCH 05/75] Fixed location and incremental model rerun --- .env.example | 1 + dbt/adapters/athena/connections.py | 9 -- dbt/adapters/athena/python_submissions.py | 30 +++-- .../macros/adapters/python_submissions.sql | 35 ++++++ .../models/incremental/incremental.sql | 45 +++++++- .../models/table/create_table_as.sql | 106 +++++++----------- tests/conftest.py | 1 + tests/unit/constants.py | 1 + tests/unit/test_python_submissions.py | 56 +++++++++ 9 files changed, 194 insertions(+), 90 deletions(-) create mode 100644 dbt/include/athena/macros/adapters/python_submissions.sql create mode 100644 tests/unit/test_python_submissions.py diff --git a/.env.example b/.env.example index cfda7881..520991b3 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ DBT_TEST_ATHENA_DATABASE= DBT_TEST_ATHENA_SCHEMA= DBT_TEST_ATHENA_WORK_GROUND= DBT_TEST_ATHENA_AWS_PROFILE_NAME= +DBT_TEST_ATHENA_SPARK_WORK_GROUP= diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index 421b00a9..a4ed8911 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -74,15 +74,6 @@ def _connection_keys(self) -> Tuple[str, ...]: "spark_work_group", ) - def get_region_name(self) -> str: - return self.region_name - - def get_profile_name(self) -> str: - return self.aws_profile_name - - def get_spark_work_group(self) -> str: - return self.spark_work_group - class AthenaCursor(Cursor): def __init__(self, **kwargs): diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 8afc8f0f..8ef7b066 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -1,5 +1,6 @@ import time -from functools import cached_property +from datetime import datetime, timedelta, timezone +from functools import lru_cache from typing import Any, Dict import boto3 @@ -11,7 +12,7 @@ DEFAULT_POLLING_INTERVAL = 2 SUBMISSION_LANGUAGE = "python" -DEFAULT_TIMEOUT = 60 * 60 * 24 +DEFAULT_TIMEOUT = 60 * 60 * 2 logger = AdapterLogger("Athena") @@ -23,17 +24,20 @@ def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.parsed_model = parsed_model self.timeout = self.get_timeout() self.polling_interval = DEFAULT_POLLING_INTERVAL - self.region_name = credentials.get_region_name() - self.profile_name = credentials.get_profile_name() - self.spark_work_group = credentials.get_spark_work_group() + self.region_name = credentials.region_name + self.profile_name = credentials.aws_profile_name + self.spark_work_group = credentials.spark_work_group - @cached_property + @property + @lru_cache() def session_id(self) -> str: - if self._list_sessions() is None: + session_info = self._list_sessions() + if session_info is None: return self._start_session().get("SessionId") - return self._list_sessions().get("SessionId") + return session_info.get("SessionId") - @cached_property + @property + @lru_cache() def athena_client(self) -> Any: return boto3.client("athena") @@ -86,7 +90,12 @@ def submit(self, compiled_code: str) -> dict: def _terminate_session(self) -> dict: try: - self.athena_client.terminate_session(SessionId=self.session_id) + session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + if session_status["State"] in ["IDLE", "BUSY"] and ( + session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) + ): + logger.debug(f"Terminating session: {self.session_id}") + self.athena_client.terminate_session(SessionId=self.session_id) except Exception: raise @@ -119,5 +128,4 @@ def _poll_until_session_creation(self, session_id): raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") def __del__(self) -> None: - logger.debug(f"Terminating session: {self.session_id}") self._terminate_session() diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql new file mode 100644 index 00000000..c9c02d24 --- /dev/null +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -0,0 +1,35 @@ +{%- macro athena__py_save_table_as(compiled_code, target_relation, format, location, mode="overwrite") -%} +{{ compiled_code }} +def materialize(spark_session, df, target_relation): + import pandas + try: + if isinstance(df, pandas.core.frame.DataFrame): + df = spark_session.createDataFrame(df) + df.write \ + .format("{{ format }}") \ + .option("path", "{{ location }}") \ + .mode("{{ mode }}") \ + .saveAsTable( + name="{{ target_relation.schema}}.{{ target_relation.identifier }}", + ) + return "OK" + except Exception: + raise + +dbt = dbtObj(spark.table) +df = model(dbt, spark) +materialize(spark, df, dbt.this) +{%- endmacro -%} + +{%- macro athena__py_execute_query(query) -%} +def execute_query(spark_session): + import pandas + try: + spark_session.sql("""{{ query }}""") + return "OK" + except Exception: + raise + +dbt = dbtObj(spark.table) +execute_query(spark) +{%- endmacro -%} diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index b6724cc2..309bf87e 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -13,6 +13,8 @@ {% set existing_relation = load_relation(this) %} {% set tmp_relation = make_temp_relation(this) %} + {{ log("temporary relation is" ~ tmp_relation.schema ~ tmp_relation.identifier)}} + -- If no partitions are used with insert_overwrite, we fall back to append mode. {% if partitioned_by is none and strategy == 'insert_overwrite' %} {% set strategy = 'append' %} @@ -25,16 +27,28 @@ {% set to_drop = [] %} {% if existing_relation is none %} - {% set build_sql = create_table_as(False, target_relation, compiled_code, language) -%} + {% call statement('save_table', language=language) -%} + {{ create_table_as(False, target_relation, compiled_code, language) }} + {%- endcall %} + {% set build_sql = None %} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} - {% set build_sql = create_table_as(False, target_relation, compiled_code, language) -%} + {% call statement('save_table', language=language) -%} + {{ create_table_as(False, target_relation, compiled_code, language) }} + {%- endcall %} + {% set build_sql = None %} {% elif partitioned_by is not none and strategy == 'insert_overwrite' %} {% set tmp_relation = make_temp_relation(target_relation) %} {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if language == 'sql' %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% else %} + {% call statement('save_table', language=language) -%} + {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {%- endcall %} + {% endif %} {% do delete_overlapping_partitions(target_relation, tmp_relation, partitioned_by) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} @@ -43,7 +57,13 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if language == 'sql' %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% else %} + {% call statement('save_table', language=language) -%} + {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {%- endcall %} + {% endif %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% elif strategy == 'merge' and table_type == 'iceberg' %} @@ -59,13 +79,26 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if language == 'sql' %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% else %} + {% call statement('save_table', language=language) -%} + {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {%- endcall %} + {% endif %} {% set build_sql = iceberg_merge(on_schema_change, tmp_relation, target_relation, unique_key, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% endif %} {% call statement("main", language=language) %} - {{ build_sql }} + {% if language == 'sql' %} + {{ build_sql }} + {% else %} + {% if build_sql %} + {{ log(build_sql) }} + {% do athena__py_execute_query(query=build_sql) %} + {% endif %} + {% endif %} {% endcall %} -- set table properties diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index cf58d47c..5205a14b 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -1,50 +1,49 @@ {% macro athena__create_table_as(temporary, relation, compiled_code, language='sql') -%} - {%- if language == 'sql' -%} - {%- set materialized = config.get('materialized', default='table') -%} - {%- set external_location = config.get('external_location', default=none) -%} - {%- set partitioned_by = config.get('partitioned_by', default=none) -%} - {%- set bucketed_by = config.get('bucketed_by', default=none) -%} - {%- set bucket_count = config.get('bucket_count', default=none) -%} - {%- set field_delimiter = config.get('field_delimiter', default=none) -%} - {%- set table_type = config.get('table_type', default='hive') | lower -%} - {%- set format = config.get('format', default='parquet') -%} - {%- set write_compression = config.get('write_compression', default=none) -%} - {%- set s3_data_dir = config.get('s3_data_dir', default=target.s3_data_dir) -%} - {%- set s3_data_naming = config.get('s3_data_naming', default=target.s3_data_naming) -%} - {%- set extra_table_properties = config.get('table_properties', default=none) -%} - - {%- set location_property = 'external_location' -%} - {%- set partition_property = 'partitioned_by' -%} - {%- set work_group_output_location = adapter.get_work_group_output_location() -%} - {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} + {%- set materialized = config.get('materialized', default='table') -%} + {%- set external_location = config.get('external_location', default=none) -%} + {%- set partitioned_by = config.get('partitioned_by', default=none) -%} + {%- set bucketed_by = config.get('bucketed_by', default=none) -%} + {%- set bucket_count = config.get('bucket_count', default=none) -%} + {%- set field_delimiter = config.get('field_delimiter', default=none) -%} + {%- set table_type = config.get('table_type', default='hive') | lower -%} + {%- set format = config.get('format', default='parquet') -%} + {%- set write_compression = config.get('write_compression', default=none) -%} + {%- set s3_data_dir = config.get('s3_data_dir', default=target.s3_data_dir) -%} + {%- set s3_data_naming = config.get('s3_data_naming', default=target.s3_data_naming) -%} + {%- set extra_table_properties = config.get('table_properties', default=none) -%} - {%- if materialized == 'table_hive_ha' -%} - {%- set location = location.replace('__ha', '') -%} - {%- endif %} + {%- set location_property = 'external_location' -%} + {%- set partition_property = 'partitioned_by' -%} + {%- set work_group_output_location = adapter.get_work_group_output_location() -%} + {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} - {%- if table_type == 'iceberg' -%} - {%- set location_property = 'location' -%} - {%- set partition_property = 'partitioning' -%} - {%- if bucketed_by is not none or bucket_count is not none -%} - {%- set ignored_bucket_iceberg -%} - bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function - when partitioning. Will be ignored - {%- endset -%} - {%- set bucketed_by = none -%} - {%- set bucket_count = none -%} - {% do log(ignored_bucket_iceberg) %} - {%- endif -%} - {%- if s3_data_naming in ['table', 'table_schema'] or external_location is not none -%} - {%- set error_unique_location_iceberg -%} - You need to have an unique table location when creating Iceberg table. Right now we are building tables in - a destructive way but in the near future we will be using the RENAME feature to provide near-zero downtime. - {%- endset -%} - {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} - {%- endif -%} - {%- endif %} + {%- if materialized == 'table_hive_ha' -%} + {%- set location = location.replace('__ha', '') -%} + {%- endif %} - {% do adapter.delete_from_s3(location) %} + {%- if table_type == 'iceberg' -%} + {%- set location_property = 'location' -%} + {%- set partition_property = 'partitioning' -%} + {%- if bucketed_by is not none or bucket_count is not none -%} + {%- set ignored_bucket_iceberg -%} + bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function + when partitioning. Will be ignored + {%- endset -%} + {%- set bucketed_by = none -%} + {%- set bucket_count = none -%} + {% do log(ignored_bucket_iceberg) %} + {%- endif -%} + {%- if s3_data_naming in ['table', 'table_schema'] or external_location is not none -%} + {%- set error_unique_location_iceberg -%} + You need to have an unique table location when creating Iceberg table. Right now we are building tables in + a destructive way but in the near future we will be using the RENAME feature to provide near-zero downtime. + {%- endset -%} + {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} + {%- endif -%} + {%- endif %} + {% do adapter.delete_from_s3(location) %} + {%- if language == 'sql' -%} create table {{ relation }} with ( table_type='{{ table_type }}', @@ -76,29 +75,8 @@ as {{ compiled_code }} {%- elif language == 'python' -%} - {{ athena__py_create_table_as(compiled_code=compiled_code, target_relation=relation, temporary=temporary) }} + {{ athena__py_save_table_as(compiled_code=compiled_code, target_relation=relation, format=format, location=location, mode="overwrite") }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} {%- endmacro -%} - -{%- macro athena__py_create_table_as(compiled_code, target_relation, temporary) -%} -{{ compiled_code }} -def materialize(spark_session, df, target_relation): - import pandas - try: - if isinstance(df, pandas.core.frame.DataFrame): - df = spark_session.createDataFrame(df) - df.write.saveAsTable( - name="{{ target_relation.schema}}.{{ target_relation.identifier }}", - format="parquet", - mode="overwrite", - ) - return "OK" - except Exception: - raise - -dbt = dbtObj(spark.table) -df = model(dbt, spark) -materialize(spark, df, dbt.this) -{%- endmacro -%} diff --git a/tests/conftest.py b/tests/conftest.py index 2233c410..fba8b34a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ def dbt_profile_target(): "num_retries": 0, "work_group": os.getenv("DBT_TEST_ATHENA_WORK_GROUND"), "aws_profile_name": os.getenv("DBT_TEST_ATHENA_AWS_PROFILE_NAME") or None, + "spark_work_group": os.getenv("DBT_TEST_ATHENA_SPARK_WORK_GROUP"), } diff --git a/tests/unit/constants.py b/tests/unit/constants.py index 4178f9be..7a7d01ac 100644 --- a/tests/unit/constants.py +++ b/tests/unit/constants.py @@ -5,3 +5,4 @@ AWS_REGION = "eu-west-1" S3_STAGING_DIR = "s3://my-bucket/test-dbt/" ATHENA_WORKGROUP = "dbt-athena-adapter" +SPARK_WORKGROUP = "spark" diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py new file mode 100644 index 00000000..56069711 --- /dev/null +++ b/tests/unit/test_python_submissions.py @@ -0,0 +1,56 @@ +from unittest.mock import patch + +from dbt.adapters.athena.connections import AthenaCredentials +from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper + +from .constants import ( + ATHENA_WORKGROUP, + AWS_REGION, + DATA_CATALOG_NAME, + DATABASE_NAME, + S3_STAGING_DIR, + SPARK_WORKGROUP, +) +from .utils import MockAWSService + + +class TestPythonSubmission: + mock_aws_service = MockAWSService() + parsed_model = {"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}} + _athena_job_helper = None + credentials = AthenaCredentials( + database=DATA_CATALOG_NAME, + schema=DATABASE_NAME, + s3_staging_dir=S3_STAGING_DIR, + region_name=AWS_REGION, + work_group=ATHENA_WORKGROUP, + spark_work_group=SPARK_WORKGROUP, + ) + + @property + def athena_job_helper(self): + if self._athena_job_helper is None: + self._athena_job_helper = AthenaPythonJobHelper(self.parsed_model, self.credentials) + return self._athena_job_helper + + def test_create_session(self): + obj = self.athena_job_helper + + # Define the mock return values for the _list_sessions and _start_session methods + mock_list_sessions = {"SessionId": "session123"} + mock_start_session = {"SessionId": "session456"} + + # Mock the _list_sessions and _start_session methods using the patch decorator + with patch.object(obj, "_list_sessions", return_value=mock_list_sessions), patch.object( + obj, "_start_session", return_value=mock_start_session + ): + # Call the session_id property and assert that it returns the expected value + assert obj.session_id == "session123" + + # Call the _list_sessions and _start_session methods to ensure they were called + obj._list_sessions.assert_called_once() + obj._start_session.assert_not_called() # since _list_sessions return value is not None + + # Call the session_id property again to ensure it still returns the expected value after + # the mock context is complete + assert obj.session_id == "session123" From 233ab98bd3543aacc01d1289fa7d6912653ec86a Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 4 Apr 2023 23:02:34 -0300 Subject: [PATCH 06/75] Added docs --- dbt/adapters/athena/python_submissions.py | 226 +++++++++++++++++----- 1 file changed, 181 insertions(+), 45 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 8ef7b066..d1ba83dc 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -18,6 +18,34 @@ class AthenaPythonJobHelper(PythonJobHelper): + """ + A helper class for executing Python jobs on AWS Athena. + + This class extends the base `PythonJobHelper` class and provides additional functionality + specific to executing jobs on Athena. It takes a parsed model and credentials as inputs + during initialization, and provides methods for executing Athena jobs, setting timeout, + polling interval, region name, AWS profile name, and Spark work group. + + Args: + parsed_model (Dict): A dictionary representing the parsed model of the Athena job. + It should contain keys such as 'alias' for job identifier and 'schema' for + job schema. + credentials (AthenaCredentials): An instance of the `AthenaCredentials` class + containing AWS credentials for accessing Athena. + + Attributes: + identifier (str): A string representing the alias or identifier of the Athena job. + schema (str): A string representing the schema of the Athena job. + parsed_model (Dict): A dictionary representing the parsed model of the Athena job. + timeout (int): An integer representing the timeout value in seconds for the Athena job. + polling_interval (int): An integer representing the polling interval in seconds for + checking the status of the Athena job. + region_name (str): A string representing the AWS region name for executing the Athena job. + profile_name (str): A string representing the AWS profile name for accessing Athena. + spark_work_group (str): A string representing the Spark work group for executing the Athena job. + + """ + def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.identifier = parsed_model["alias"] self.schema = parsed_model["schema"] @@ -31,6 +59,16 @@ def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: @property @lru_cache() def session_id(self) -> str: + """ + Get the session ID. + + This function retrieves the session ID from the stored session information. If session information + is not available, a new session is started and its session ID is returned. + + Returns: + str: The session ID. + + """ session_info = self._list_sessions() if session_info is None: return self._start_session().get("SessionId") @@ -39,67 +77,150 @@ def session_id(self) -> str: @property @lru_cache() def athena_client(self) -> Any: - return boto3.client("athena") + """ + Get the AWS Athena client. + + This function returns an AWS Athena client object that can be used to interact with the Athena service. + The client is created using the region name and profile name provided during object instantiation. + + Returns: + Any: The Athena client object. + + """ + return boto3.session.Session(region_name=self.region_name, profile_name=self.profile_name).client("athena") def get_timeout(self) -> int: + """ + Get the timeout value. + + This function retrieves the timeout value from the parsed model's configuration. If the timeout value + is not defined, it falls back to the default timeout value. If the retrieved timeout value is less than or + equal to 0, a ValueError is raised as timeout must be a positive integer. + + Returns: + int: The timeout value in seconds. + + Raises: + ValueError: If the timeout value is not a positive integer. + + """ timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) if timeout <= 0: raise ValueError("Timeout must be a positive integer") return timeout def _list_sessions(self) -> dict: - try: - response = self.athena_client.list_sessions( - WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE" - ) - if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: - return None - return response.get("Sessions")[0] - except Exception: - raise + """ + List Athena sessions. + + This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. + It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions + are found or if an error occurs, None is returned. + + Returns: + dict: The session information dictionary if an idle session is found, None otherwise. + + """ + response = self.athena_client.list_sessions(WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE") + if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: + return None + return response.get("Sessions")[0] def _start_session(self) -> dict: - try: - response = self.athena_client.start_session( - WorkGroup=self.spark_work_group, - EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, - ) - if response["State"] != "IDLE": - self._poll_until_session_creation(response["SessionId"]) - return response - except Exception: - raise + """ + Start an Athena session. + + This function sends a request to the Athena service to start a session in the specified Spark workgroup. + It configures the session with specific engine configurations. If the session state is not IDLE, the function + polls until the session creation is complete. The response containing session information is returned. + + Returns: + dict: The session information dictionary. + + """ + response = self.athena_client.start_session( + WorkGroup=self.spark_work_group, + EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + ) + if response["State"] != "IDLE": + self._poll_until_session_creation(response["SessionId"]) + return response def submit(self, compiled_code: str) -> dict: - try: - calculation_execution_id = self.athena_client.start_calculation_execution( - SessionId=self.session_id, CodeBlock=compiled_code.lstrip() - )["CalculationExecutionId"] - logger.debug(f"Submitted calculation execution id {calculation_execution_id}") - execution_status = self._poll_until_execution_completion(calculation_execution_id) - logger.debug(f"Received execution status {execution_status}") - if execution_status == "COMPLETED": - result_s3_uri = self.athena_client.get_calculation_execution( - CalculationExecutionId=calculation_execution_id - )["Result"]["ResultS3Uri"] - return result_s3_uri - else: - raise DbtRuntimeError(f"python model run ended in state {execution_status}") - except Exception: - raise + """ + Submit a calculation to Athena. + + This function submits a calculation to Athena for execution using the provided compiled code. + It starts a calculation execution with the current session ID and the compiled code as the code block. + The function then polls until the calculation execution is completed, and retrieves the result S3 URI. + If the execution is successful and completed, the result S3 URI is returned. Otherwise, a DbtRuntimeError + is raised with the execution status. + + Args: + compiled_code (str): The compiled code to submit for execution. + + Returns: + dict: The result S3 URI if the execution is successful and completed. + + Raises: + DbtRuntimeError: If the execution ends in a state other than "COMPLETED". + + """ + calculation_execution_id = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.lstrip() + )["CalculationExecutionId"] + logger.debug(f"Submitted calculation execution id {calculation_execution_id}") + execution_status = self._poll_until_execution_completion(calculation_execution_id) + logger.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + result_s3_uri = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"]["ResultS3Uri"] + return result_s3_uri + else: + raise DbtRuntimeError(f"python model run ended in state {execution_status}") def _terminate_session(self) -> dict: - try: - session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] - if session_status["State"] in ["IDLE", "BUSY"] and ( - session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) - ): - logger.debug(f"Terminating session: {self.session_id}") - self.athena_client.terminate_session(SessionId=self.session_id) - except Exception: - raise + """ + Terminate the current Athena session. + + This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the + configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the + duration since the session start time exceeds the timeout period, the session is terminated. The session ID is + used to terminate the session via the Athena client. + + Returns: + dict: The response from the Athena client after terminating the session. + + """ + session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + if session_status["State"] in ["IDLE", "BUSY"] and ( + session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) + ): + logger.debug(f"Terminating session: {self.session_id}") + self.athena_client.terminate_session(SessionId=self.session_id) def _poll_until_execution_completion(self, calculation_execution_id): + """ + Poll the status of a calculation execution until it is completed, failed, or cancelled. + + This function polls the status of a calculation execution identified by the given `calculation_execution_id` + until it is completed, failed, or cancelled. It uses the Athena client to retrieve the status of the execution + and checks if the state is one of "COMPLETED", "FAILED", or "CANCELLED". If the execution is not yet completed, + the function sleeps for a certain polling interval, which starts with the value of `self.polling_interval` and + doubles after each iteration until it reaches the `self.timeout` period. If the execution does not complete + within the timeout period, a `DbtRuntimeError` is raised. + + Args: + calculation_execution_id (str): The ID of the calculation execution to poll. + + Returns: + str: The final state of the calculation execution, which can be one of "COMPLETED", "FAILED" or "CANCELLED". + + Raises: + DbtRuntimeError: If the calculation execution does not complete within the timeout period. + + """ polling_interval = self.polling_interval while True: execution_status = self.athena_client.get_calculation_execution_status( @@ -115,6 +236,20 @@ def _poll_until_execution_completion(self, calculation_execution_id): ) def _poll_until_session_creation(self, session_id): + """ + Polls the status of an Athena session creation until it is completed or reaches the timeout. + + Args: + session_id (str): The ID of the session being created. + + Returns: + str: The final status of the session, which will be "IDLE" if the session creation is successful. + + Raises: + DbtRuntimeError: If the session creation fails, is terminated, or degrades during polling. + DbtRuntimeError: If the session does not become IDLE within the specified timeout. + + """ polling_interval = self.polling_interval while True: creation_status = self.athena_client.get_session_status(SessionId=session_id)["Status"]["State"] @@ -128,4 +263,5 @@ def _poll_until_session_creation(self, session_id): raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") def __del__(self) -> None: + """Teardown for the class.""" self._terminate_session() From d2ad09e5ba35739a1076bfd659d1028590c855b3 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 4 Apr 2023 23:14:12 -0300 Subject: [PATCH 07/75] Updated README --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 280ead04..c143fed0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ * Support two incremental update strategies: `insert_overwrite` and `append` * Does **not** support the use of `unique_key` * Supports [snapshots][snapshots] -* Does not support [Python models][python-models] +* Supports [Python models][python-models] * Does not support [persist docs][persist-docs] for views [seeds]: https://docs.getdbt.com/docs/building-a-dbt-project/seeds @@ -73,6 +73,7 @@ A dbt profile can be configured to run against AWS Athena using the following co | work_group | Identifier of Athena workgroup | Optional | `my-custom-workgroup` | | num_retries | Number of times to retry a failing query | Optional | `3` | | lf_tags | Default lf tags to apply to any database created by dbt | Optional | `{"origin": "dbt", "team": "analytics"}` | +| spark_work_group | Identifier of athena spark workgroup | Optional | `my-spark-workgroup` | **Example profiles.yml entry:** ```yaml @@ -89,6 +90,7 @@ athena: database: awsdatacatalog aws_profile_name: my-profile work_group: my-workgroup + spark_work_group: my-spark-workgroup lf_tags: origin: dbt team: analytics @@ -373,6 +375,35 @@ The only way, from a dbt perspective, is to do a full-refresh of the incremental * Snapshot does not support dropping columns from the source table. If you drop a column make sure to drop the column from the snapshot as well. Another workaround is to NULL the column in the snapshot definition to preserve history +### Python Models + +The adapter supports python models using [`spark`](https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark.html). + +#### Prerequisites + +* A spark enabled work group created in athena +* Spark execution role granted access to Athena, Glue and S3 +* The spark work group is added to the ~/.dbt/profiles.yml file and the profile is referenced in dbt_project.yml + +#### Example model + +```python +import pandas as pd + + +def model(dbt, session): + dbt.config(materialized="table") + + model_df = pd.DataFrame({"A": [1, 2, 3, 4]}) + + return model_df +``` + +#### Known issues in python models + +* Incremental models do not fully utilize spark capabilities. They depend on existing sql based logic. +* Snapshots materializations are not supported. + ### Contributing This connector works with Python from 3.7 to 3.11. From fed0dfacce8e816be7adfd0b4b03b66b33bfd8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guiselin?= <9251353+Jrmyy@users.noreply.github.com> Date: Wed, 5 Apr 2023 17:51:58 +0200 Subject: [PATCH 08/75] fix: return empty if table does not exist in get_columns (#199) --- dbt/adapters/athena/impl.py | 10 +++++++++- tests/unit/test_adapter.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index 53b69cdd..e34ebb32 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -696,7 +696,15 @@ def get_columns_in_relation(self, relation: AthenaRelation) -> List[Column]: with boto3_client_lock: glue_client = client.session.client("glue", region_name=client.region_name, config=get_boto3_config()) - table = glue_client.get_table(DatabaseName=relation.schema, Name=relation.identifier)["Table"] + try: + table = glue_client.get_table(DatabaseName=relation.schema, Name=relation.identifier)["Table"] + except ClientError as e: + if e.response["Error"]["Code"] == "EntityNotFoundException": + logger.debug("table not exist, catching the error") + return [] + else: + logger.error(e) + raise e columns = [c for c in table["StorageDescriptor"]["Columns"] if self._is_current_column(c)] partition_keys = table.get("PartitionKeys", []) diff --git a/tests/unit/test_adapter.py b/tests/unit/test_adapter.py index 01685291..4a7d10d9 100644 --- a/tests/unit/test_adapter.py +++ b/tests/unit/test_adapter.py @@ -848,6 +848,21 @@ def test_get_columns_in_relation(self): Column("dt", "date"), ] + @mock_athena + @mock_glue + def test_get_columns_in_relation_not_found_table(self): + self.mock_aws_service.create_data_catalog() + self.mock_aws_service.create_database() + self.adapter.acquire_connection("dummy") + columns = self.adapter.get_columns_in_relation( + self.adapter.Relation.create( + database=DATA_CATALOG_NAME, + schema=DATABASE_NAME, + identifier="tbl_name", + ) + ) + assert columns == [] + @pytest.mark.parametrize( "response,database,table,columns,lf_tags,expected", [ From f5c816774e0588004bddcd82da604d9830e243ad Mon Sep 17 00:00:00 2001 From: nicor88 <6278547+nicor88@users.noreply.github.com> Date: Thu, 6 Apr 2023 11:40:16 +0200 Subject: [PATCH 09/75] fix: check for empty workgroup in profiles (#194) --- dbt/adapters/athena/impl.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index e34ebb32..535378f3 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -149,13 +149,14 @@ def get_work_group_output_location(self) -> Optional[str]: with boto3_client_lock: athena_client = client.session.client("athena", region_name=client.region_name, config=get_boto3_config()) - work_group = athena_client.get_work_group(WorkGroup=creds.work_group) - return ( - work_group.get("WorkGroup", {}) - .get("Configuration", {}) - .get("ResultConfiguration", {}) - .get("OutputLocation") - ) + if creds.work_group: + work_group = athena_client.get_work_group(WorkGroup=creds.work_group) + return ( + work_group.get("WorkGroup", {}) + .get("Configuration", {}) + .get("ResultConfiguration", {}) + .get("OutputLocation") + ) @available def s3_table_prefix(self, s3_data_dir: Optional[str]) -> str: From 785ba5fd9eb880e695360efd3ff1a9b1a8f05bf1 Mon Sep 17 00:00:00 2001 From: Mattia <5013654+mattiamatrix@users.noreply.github.com> Date: Thu, 6 Apr 2023 12:03:23 +0100 Subject: [PATCH 10/75] chore: add credits section (#201) --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c143fed0..b2424498 100644 --- a/README.md +++ b/README.md @@ -446,8 +446,25 @@ make test [conventionalcommits](https://www.conventionalcommits.org). * Pull request body should describe _motivation_. -### Helpful Resources +## Credits +The following acknowledges the Maintainers for this repository, those who have Contributed to this repository (via bug reports, code, design, ideas, project management, translation, testing, etc.), and any other References utilized. +### Maintainers +The following individuals are responsible for curating the list of issues, responding to pull requests, and ensuring regular releases happen. + +* [nicor88](https://github.com/nicor88) +* [Jrmyy](https://github.com/Jrmyy) +* [jessedobbelaere](https://github.com/jessedobbelaere) +* [mattiamatrix](https://github.com/mattiamatrix) +* [thenaturalist](https://github.com/thenaturalist) + +### Contributors +Thank you to all the people who have already contributed to this repository via bug reports, code, design, ideas, project management, translation, testing, etc. + +* [Tomme](https://github.com/Tomme) - Wrote the initial version. +* [Lemiffe](https://github.com/lemiffe) - Logo design. + +## Resources * [Athena CREATE TABLE AS](https://docs.aws.amazon.com/athena/latest/ug/create-table-as.html) * [dbt-labs/dbt-core](https://github.com/dbt-labs/dbt-core) * [laughingman7743/PyAthena](https://github.com/laughingman7743/PyAthena) From ee8bf9581d76858ae2508fd545017808a18bf70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guiselin?= <9251353+Jrmyy@users.noreply.github.com> Date: Thu, 6 Apr 2023 15:25:49 +0200 Subject: [PATCH 11/75] fix: enable database in policy to support cross-account queries (#200) Co-authored-by: nicor88 <6278547+nicor88@users.noreply.github.com> --- dbt/adapters/athena/relation.py | 2 +- tests/unit/test_relation.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dbt/adapters/athena/relation.py b/dbt/adapters/athena/relation.py index 887158c7..414d2dc9 100644 --- a/dbt/adapters/athena/relation.py +++ b/dbt/adapters/athena/relation.py @@ -6,7 +6,7 @@ @dataclass class AthenaIncludePolicy(Policy): - database: bool = False + database: bool = True schema: bool = True identifier: bool = True diff --git a/tests/unit/test_relation.py b/tests/unit/test_relation.py index 21f64973..81872224 100644 --- a/tests/unit/test_relation.py +++ b/tests/unit/test_relation.py @@ -12,7 +12,7 @@ def test_render_hive_uses_hive_style_quotation(self): database=DATA_CATALOG_NAME, schema=DATABASE_NAME, ) - assert relation.render_hive() == f"`{DATABASE_NAME}`.`{TABLE_NAME}`" + assert relation.render_hive() == f"`{DATA_CATALOG_NAME}`.`{DATABASE_NAME}`.`{TABLE_NAME}`" def test_render_hive_resets_quote_character_after_call(self): relation = AthenaRelation.create( @@ -21,7 +21,7 @@ def test_render_hive_resets_quote_character_after_call(self): schema=DATABASE_NAME, ) relation.render_hive() - assert relation.render() == f'"{DATABASE_NAME}"."{TABLE_NAME}"' + assert relation.render() == f'"{DATA_CATALOG_NAME}"."{DATABASE_NAME}"."{TABLE_NAME}"' def test_render_pure_resets_quote_character_after_call(self): relation = AthenaRelation.create( @@ -29,4 +29,4 @@ def test_render_pure_resets_quote_character_after_call(self): database=DATA_CATALOG_NAME, schema=DATABASE_NAME, ) - assert relation.render_pure() == f"{DATABASE_NAME}.{TABLE_NAME}" + assert relation.render_pure() == f"{DATA_CATALOG_NAME}.{DATABASE_NAME}.{TABLE_NAME}" From 414c739336db45b481881d6f8e885971c8071b49 Mon Sep 17 00:00:00 2001 From: Serhii Dimchenko <39801237+svdimchenko@users.noreply.github.com> Date: Fri, 7 Apr 2023 09:43:23 +0200 Subject: [PATCH 12/75] fix: glue column types (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: nicor88 <6278547+nicor88@users.noreply.github.com> Co-authored-by: Jérémy Guiselin <9251353+Jrmyy@users.noreply.github.com> --- dbt/adapters/athena/column.py | 55 +++++++++++++++++++++++++++++++++ dbt/adapters/athena/impl.py | 44 +++++++++++++++++--------- dbt/adapters/athena/relation.py | 9 ++++++ tests/unit/test_adapter.py | 16 +++++----- 4 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 dbt/adapters/athena/column.py diff --git a/dbt/adapters/athena/column.py b/dbt/adapters/athena/column.py new file mode 100644 index 00000000..e5dfdadd --- /dev/null +++ b/dbt/adapters/athena/column.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass + +from dbt.adapters.athena.relation import TableType +from dbt.adapters.base.column import Column +from dbt.exceptions import DbtRuntimeError + + +@dataclass +class AthenaColumn(Column): + table_type: TableType = TableType.TABLE + + def is_iceberg(self) -> bool: + return self.table_type == TableType.ICEBERG + + def is_string(self) -> bool: + return self.dtype.lower() in {"varchar", "string"} + + def is_binary(self) -> bool: + return self.dtype.lower() in {"binary", "varbinary"} + + def is_timestamp(self) -> bool: + return self.dtype.lower() in {"timestamp"} + + @classmethod + def string_type(cls, size: int) -> str: + return f"varchar({size})" if size > 0 else "varchar" + + @classmethod + def binary_type(cls) -> str: + return "varbinary" + + def timestamp_type(self) -> str: + if self.is_iceberg(): + return "timestamp(6)" + return "timestamp" + + def string_size(self) -> int: + if not self.is_string(): + raise DbtRuntimeError("Called string_size() on non-string field!") + if not self.char_size: + # Handle error: '>' not supported between instances of 'NoneType' and 'NoneType' for union relations macro + return 0 + return self.char_size + + @property + def data_type(self) -> str: + if self.is_string(): + return self.string_type(self.string_size()) + elif self.is_numeric(): + return self.numeric_type(self.dtype, self.numeric_precision, self.numeric_scale) + elif self.is_binary(): + return self.binary_type() + elif self.is_timestamp(): + return self.timestamp_type() + return self.dtype diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index 535378f3..e42b6f16 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -12,11 +12,22 @@ from botocore.exceptions import ClientError from dbt.adapters.athena import AthenaConnectionManager +from dbt.adapters.athena.column import AthenaColumn from dbt.adapters.athena.config import get_boto3_config +<<<<<<< HEAD from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper from dbt.adapters.athena.relation import AthenaRelation, AthenaSchemaSearchMap from dbt.adapters.athena.utils import clean_sql_comment from dbt.adapters.base import Column, PythonJobHelper, available +======= +from dbt.adapters.athena.relation import ( + AthenaRelation, + AthenaSchemaSearchMap, + TableType, +) +from dbt.adapters.athena.utils import clean_sql_comment +from dbt.adapters.base import available +>>>>>>> 6f41803 (fix: glue column types (#196)) from dbt.adapters.base.relation import BaseRelation, InformationSchema from dbt.adapters.sql import SQLAdapter from dbt.contracts.connection import AdapterResponse @@ -35,13 +46,13 @@ class AthenaAdapter(SQLAdapter): Relation = AthenaRelation relation_type_map = { - "EXTERNAL_TABLE": "table", - "MANAGED_TABLE": "table", - "VIRTUAL_VIEW": "view", - "table": "table", - "view": "view", - "cte": "cte", - "materializedview": "materializedview", + "EXTERNAL_TABLE": TableType.TABLE, + "MANAGED_TABLE": TableType.TABLE, + "VIRTUAL_VIEW": TableType.VIEW, + "table": TableType.TABLE, + "view": TableType.VIEW, + "cte": TableType.CTE, + "materializedview": TableType.MATERIALIZED_VIEW, } @classmethod @@ -366,7 +377,7 @@ def _get_one_table_for_catalog(self, table: dict, database: str) -> list: "table_database": database, "table_schema": table["DatabaseName"], "table_name": table["Name"], - "table_type": self.relation_type_map[table["TableType"]], + "table_type": self.relation_type_map[table["TableType"]].value, "table_comment": table.get("Parameters", {}).get("comment", table.get("Description", "")), } return [ @@ -486,7 +497,7 @@ def list_relations_without_caching( return relations @available - def get_table_type(self, db_name, table_name): + def get_table_type(self, db_name, table_name) -> TableType: conn = self.connections.get_thread_connection() client = conn.handle @@ -495,17 +506,17 @@ def get_table_type(self, db_name, table_name): try: response = glue_client.get_table(DatabaseName=db_name, Name=table_name) - _type = self.relation_type_map.get(response.get("Table", {}).get("TableType", "Table")) + _type = self.relation_type_map.get(response.get("Table", {}).get("TableType")) _specific_type = response.get("Table", {}).get("Parameters", {}).get("table_type", "") if _specific_type.lower() == "iceberg": - _type = "iceberg_table" + _type = TableType.ICEBERG if _type is None: raise ValueError("Table type cannot be None") - logger.debug("table_name : " + table_name) - logger.debug("table type : " + _type) + logger.debug(f"table_name : {table_name}") + logger.debug(f"table type : {_type}") return _type @@ -690,7 +701,7 @@ def _is_current_column(col: dict) -> bool: return True @available - def get_columns_in_relation(self, relation: AthenaRelation) -> List[Column]: + def get_columns_in_relation(self, relation: AthenaRelation) -> List[AthenaColumn]: conn = self.connections.get_thread_connection() client = conn.handle @@ -706,10 +717,13 @@ def get_columns_in_relation(self, relation: AthenaRelation) -> List[Column]: else: logger.error(e) raise e + table_type = self.get_table_type(relation.schema, relation.identifier) columns = [c for c in table["StorageDescriptor"]["Columns"] if self._is_current_column(c)] partition_keys = table.get("PartitionKeys", []) logger.debug(f"Columns in relation {relation.identifier}: {columns + partition_keys}") - return [Column(c["Name"], c["Type"]) for c in columns + partition_keys] + return [ + AthenaColumn(column=c["Name"], dtype=c["Type"], table_type=table_type) for c in columns + partition_keys + ] diff --git a/dbt/adapters/athena/relation.py b/dbt/adapters/athena/relation.py index 414d2dc9..0cf51947 100644 --- a/dbt/adapters/athena/relation.py +++ b/dbt/adapters/athena/relation.py @@ -1,9 +1,18 @@ from dataclasses import dataclass, field +from enum import Enum from typing import Dict, Optional, Set from dbt.adapters.base.relation import BaseRelation, InformationSchema, Policy +class TableType(Enum): + TABLE = "table" + VIEW = "view" + CTE = "cte" + MATERIALIZED_VIEW = "materializedview" + ICEBERG = "iceberg_table" + + @dataclass class AthenaIncludePolicy(Policy): database: bool = True diff --git a/tests/unit/test_adapter.py b/tests/unit/test_adapter.py index 4a7d10d9..d69bdc69 100644 --- a/tests/unit/test_adapter.py +++ b/tests/unit/test_adapter.py @@ -10,9 +10,9 @@ from dbt.adapters.athena import AthenaAdapter from dbt.adapters.athena import Plugin as AthenaPlugin +from dbt.adapters.athena.column import AthenaColumn from dbt.adapters.athena.connections import AthenaCursor, AthenaParameterFormatter -from dbt.adapters.athena.relation import AthenaRelation -from dbt.adapters.base import Column +from dbt.adapters.athena.relation import AthenaRelation, TableType from dbt.clients import agate_helper from dbt.contracts.connection import ConnectionState from dbt.contracts.files import FileHash @@ -436,7 +436,7 @@ def test__get_relation_type_table(self, aws_credentials): self.mock_aws_service.create_table("test_table") self.adapter.acquire_connection("dummy") table_type = self.adapter.get_table_type(DATABASE_NAME, "test_table") - assert table_type == "table" + assert table_type == TableType.TABLE @mock_glue @mock_s3 @@ -459,7 +459,7 @@ def test__get_relation_type_view(self, aws_credentials): self.mock_aws_service.create_view("test_view") self.adapter.acquire_connection("dummy") table_type = self.adapter.get_table_type(DATABASE_NAME, "test_view") - assert table_type == "view" + assert table_type == TableType.VIEW @mock_glue @mock_s3 @@ -470,7 +470,7 @@ def test__get_relation_type_iceberg(self, aws_credentials): self.mock_aws_service.create_iceberg_table("test_iceberg") self.adapter.acquire_connection("dummy") table_type = self.adapter.get_table_type(DATABASE_NAME, "test_iceberg") - assert table_type == "iceberg_table" + assert table_type == TableType.ICEBERG def _test_list_relations_without_caching(self, schema_relation): self.adapter.acquire_connection("dummy") @@ -843,9 +843,9 @@ def test_get_columns_in_relation(self): ) ) assert columns == [ - Column("id", "string"), - Column("country", "string"), - Column("dt", "date"), + AthenaColumn(column="id", dtype="string", table_type=TableType.TABLE), + AthenaColumn(column="country", dtype="string", table_type=TableType.TABLE), + AthenaColumn(column="dt", dtype="date", table_type=TableType.TABLE), ] @mock_athena From 6ad67e7cbc23bf5cee76193a7db3cd2592b52b86 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 18:01:13 +0100 Subject: [PATCH 13/75] docs: add nicor88 as a contributor for code (#205) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 26 ++++++++++++++++++++++++++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 .all-contributorsrc diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 00000000..b499a8b1 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,26 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "commitConvention": "angular", + "contributors": [ + { + "login": "nicor88", + "name": "nicor88", + "avatar_url": "https://avatars.githubusercontent.com/u/6278547?v=4", + "profile": "https://github.com/nicor88", + "contributions": [ + "code", + "maintenance" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true, + "repoType": "github", + "repoHost": "https://github.com", + "projectName": "dbt-athena", + "projectOwner": "dbt-athena" +} diff --git a/README.md b/README.md index b2424498..185db8f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ [![pypi](https://badge.fury.io/py/dbt-athena-community.svg)](https://pypi.org/project/dbt-athena-community/) + +[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) + [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Stats: pepy](https://pepy.tech/badge/dbt-athena-community/month)](https://pepy.tech/project/dbt-athena-community) @@ -468,3 +471,25 @@ Thank you to all the people who have already contributed to this repository via * [Athena CREATE TABLE AS](https://docs.aws.amazon.com/athena/latest/ug/create-table-as.html) * [dbt-labs/dbt-core](https://github.com/dbt-labs/dbt-core) * [laughingman7743/PyAthena](https://github.com/laughingman7743/PyAthena) + +## Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + +
nicor88
nicor88

💻 🚧
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file From 1c35b6e2fe578a636a51dcd5b237ca010c4e8f93 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 18:07:28 +0100 Subject: [PATCH 14/75] docs: add jessedobbelaere as a contributor for bug (#210) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index b499a8b1..0f6b58f8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -15,6 +15,15 @@ "code", "maintenance" ] + }, + { + "login": "jessedobbelaere", + "name": "Jesse Dobbelaere", + "avatar_url": "https://avatars.githubusercontent.com/u/1352979?v=4", + "profile": "https://jessedobbelae.re", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 185db8f4..8c6ea621 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![pypi](https://badge.fury.io/py/dbt-athena-community.svg)](https://pypi.org/project/dbt-athena-community/) -[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -483,6 +483,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d nicor88
nicor88

💻 🚧 + Jesse Dobbelaere
Jesse Dobbelaere

🐛 From 3150170b59114d4057b54449deeedce2a72cba0f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 19:15:19 +0100 Subject: [PATCH 15/75] docs: add lemiffe as a contributor for design (#209) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Mattia <5013654+mattiamatrix@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 0f6b58f8..ae15c652 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -24,6 +24,15 @@ "contributions": [ "bug" ] + }, + { + "login": "lemiffe", + "name": "Lemiffe", + "avatar_url": "https://avatars.githubusercontent.com/u/7487772?v=4", + "profile": "https://github.com/lemiffe", + "contributions": [ + "design" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 8c6ea621..1821e61f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![pypi](https://badge.fury.io/py/dbt-athena-community.svg)](https://pypi.org/project/dbt-athena-community/) -[![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -484,6 +484,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d nicor88
nicor88

💻 🚧 Jesse Dobbelaere
Jesse Dobbelaere

🐛 + Lemiffe
Lemiffe

🎨 @@ -493,4 +494,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! From 4074bc7ff075a88fdc1b6624d21b4e73881e1e98 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 19:22:32 +0100 Subject: [PATCH 16/75] docs: add jessedobbelaere as a contributor for maintenance (#212) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index ae15c652..c3a4f99b 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -22,7 +22,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/1352979?v=4", "profile": "https://jessedobbelae.re", "contributions": [ - "bug" + "bug", + "maintenance" ] }, { diff --git a/README.md b/README.md index 1821e61f..1cbb8e52 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d nicor88
nicor88

💻 🚧 - Jesse Dobbelaere
Jesse Dobbelaere

🐛 + Jesse Dobbelaere
Jesse Dobbelaere

🐛 🚧 Lemiffe
Lemiffe

🎨 From 439bc7570182adaeff27c15baae888664fca1926 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 19:23:16 +0100 Subject: [PATCH 17/75] docs: add Jrmyy as a contributor for maintenance (#207) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Mattia <5013654+mattiamatrix@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index c3a4f99b..1423ee17 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -34,6 +34,15 @@ "contributions": [ "design" ] + }, + { + "login": "Jrmyy", + "name": "Jérémy Guiselin", + "avatar_url": "https://avatars.githubusercontent.com/u/9251353?v=4", + "profile": "https://github.com/Jrmyy", + "contributions": [ + "maintenance" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 1cbb8e52..6a314221 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![pypi](https://badge.fury.io/py/dbt-athena-community.svg)](https://pypi.org/project/dbt-athena-community/) -[![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors-) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -485,6 +485,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d nicor88
nicor88

💻 🚧 Jesse Dobbelaere
Jesse Dobbelaere

🐛 🚧 Lemiffe
Lemiffe

🎨 + Jérémy Guiselin
Jérémy Guiselin

🚧 From 3d31ab9ad0808cf52a89f61c944ceb4ee3b7d313 Mon Sep 17 00:00:00 2001 From: Mattia <5013654+mattiamatrix@users.noreply.github.com> Date: Sat, 8 Apr 2023 14:46:47 +0100 Subject: [PATCH 18/75] docs: add dbt-athena-logo (#211) --- static/images/dbt-athena-black.png | Bin 0 -> 25422 bytes static/images/dbt-athena-color.png | Bin 0 -> 112239 bytes static/images/dbt-athena-long.png | Bin 0 -> 152087 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/dbt-athena-black.png create mode 100644 static/images/dbt-athena-color.png create mode 100644 static/images/dbt-athena-long.png diff --git a/static/images/dbt-athena-black.png b/static/images/dbt-athena-black.png new file mode 100644 index 0000000000000000000000000000000000000000..b7ba90b7c00c373e1fc69c536c4d140bb251c408 GIT binary patch literal 25422 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 literal 0 HcmV?d00001 diff --git a/static/images/dbt-athena-color.png b/static/images/dbt-athena-color.png new file mode 100644 index 0000000000000000000000000000000000000000..aca1b51b5471ef9b4d056243a4837f51121609e3 GIT binary patch literal 112239 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 literal 0 HcmV?d00001 diff --git a/static/images/dbt-athena-long.png b/static/images/dbt-athena-long.png new file mode 100644 index 0000000000000000000000000000000000000000..22cb5ccddbd8d87a54eb197bc830f42c5cd36cbe GIT binary patch literal 152087 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 Date: Sat, 8 Apr 2023 17:04:27 +0100 Subject: [PATCH 19/75] docs: update readme (#204) --- README.md | 78 +++++++++++++++++++++---------------------------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 6a314221..0635a8a5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -[![pypi](https://badge.fury.io/py/dbt-athena-community.svg)](https://pypi.org/project/dbt-athena-community/) - -[![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors-) - -[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Stats: pepy](https://pepy.tech/badge/dbt-athena-community/month)](https://pepy.tech/project/dbt-athena-community) +

    + + + + + +

    -# dbt-athena +## Features * Supports dbt version `1.4.*` * Supports [Seeds][seeds] @@ -34,6 +34,9 @@ [snapshots]: https://docs.getdbt.com/docs/build/snapshots [persist-docs]: https://docs.getdbt.com/reference/resource-configs/persist_docs + +## Quick Start + ### Installation * `pip install dbt-athena-community` @@ -103,9 +106,10 @@ _Additional information_ * `threads` is supported * `database` and `catalog` can be used interchangeably -### Models -#### Table Configuration +## Models + +### Table Configuration * `external_location` (`default=none`) * If set, the full S3 path in which the table will be saved. (Does not work with Iceberg table). @@ -134,7 +138,7 @@ _Additional information_ * lf tags to associate with the table columns * format: `{"tag1": {"value1": ["column1": "column2"]}}` -#### Table location +### Table location The location in which a table is saved is determined by: @@ -154,8 +158,7 @@ or setting up the value for groups of model in dbt_project.yml. > Note: when using a work group with a default output location configured, `s3_data_naming` and any configured buckets are ignored and the location configured in the work group is used. - -#### Incremental models +### Incremental models Support for [incremental models](https://docs.getdbt.com/docs/build/incremental-models). @@ -169,7 +172,7 @@ data (e.g. great for log or historical data). * `merge`: Conditionally updates, deletes, or inserts rows into an Iceberg table. Used in combination with `unique_key`. Only available when using Iceberg. -#### On schema change +### On schema change `on_schema_change` is an option to reflect changes of schema in incremental models. The following options are supported: @@ -180,8 +183,7 @@ The following options are supported: In detail, please refer to [dbt docs](https://docs.getdbt.com/docs/build/incremental-models#what-if-the-columns-of-my-incremental-model-change). - -#### Iceberg +### Iceberg The adapter supports table materialization for Iceberg. @@ -216,7 +218,7 @@ It is possible to use iceberg in an incremental fashion, specifically 2 strategi * `merge`: must be used in combination with `unique_key` and it's only available with Engine version 3. It performs an upsert, new record are added, and record already existing are updated -#### High available table materialization +### High available table materialization The current implementation of the table materialization can lead to downtime, as target table is dropped and re-created. To have the less destructive behavior it's possible to use `table='table_hive_ha'` materialization. **table_hive_ha** leverage the table versions feature of glue catalog, creating a tmp table and swapping @@ -231,7 +233,6 @@ This materialization is only available for `table_type=hive` and requires using s3_data_naming='table_unique' ) }} - select 'a' as user_id, 'pi' as user_name, @@ -245,7 +246,7 @@ select By default, the materialization keeps the last 4 table versions, you can change it that setting `versions_to_keep`. -##### Known issues +#### Known issues * When swapping from a table with partitions to a table without (and the other way around), there could be a little downtime. In case high performances are needed consider bucketing instead of partitions * By default, Glue "duplicate" the versions internally, so the last 2 versions of a table point to the same location @@ -253,19 +254,19 @@ By default, the materialization keeps the last 4 table versions, you can change * The macro athena__end_of_time needs to be overwritten by the user if using Athena v3 since it requires a precision parameter for timestamps -### Snapshots +## Snapshots The adapter supports snapshot materialization. It supports both timestamp and check strategy. To create a snapshot create a snapshot file in the snapshots directory. If directory does not exist create one. -#### Timestamp strategy +### Timestamp strategy To use the timestamp strategy refer to the [dbt docs](https://docs.getdbt.com/docs/build/snapshots#timestamp-strategy-recommended) -#### Check strategy +### Check strategy To use the check strategy refer to the [dbt docs](https://docs.getdbt.com/docs/build/snapshots#check-strategy) -#### Hard-deletes +### Hard-deletes The materialization also supports invalidating hard deletes. Check the [docs](https://docs.getdbt.com/docs/build/snapshots#hard-deletes-opt-in) to understand usage. @@ -301,7 +302,6 @@ model.sql materialized='table' ) }} - SELECT ROW_NUMBER() OVER () AS id , * @@ -322,8 +322,6 @@ timestamp strategy - model_snapshot_1 ) }} - - SELECT * from {{ ref('model') }} @@ -407,11 +405,12 @@ def model(dbt, session): * Incremental models do not fully utilize spark capabilities. They depend on existing sql based logic. * Snapshots materializations are not supported. -### Contributing + +## Contributing This connector works with Python from 3.7 to 3.11. -#### Getting started +### Getting started In order to start developing on this adapter clone the repo and run this make command (see [Makefile](Makefile)) : @@ -426,20 +425,19 @@ It will : Next, adjust `.env` file by configuring the environment variables to match your Athena development environment. -#### Running tests +### Running tests We have 2 different types of testing: * **unit testing**: you can run this type of tests running `make unit_test` * **functional testing**: you must have an AWS account with Athena setup in order to launch this type of tests and have a `.env` file in place with the right values. You can run this type of tests running `make functional_test` - All type of tests can be run using `make`: ```bash make test ``` -#### Pull Request +### Pull Request * Create a commit with your changes and push them to a [fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo). @@ -449,29 +447,13 @@ make test [conventionalcommits](https://www.conventionalcommits.org). * Pull request body should describe _motivation_. -## Credits -The following acknowledges the Maintainers for this repository, those who have Contributed to this repository (via bug reports, code, design, ideas, project management, translation, testing, etc.), and any other References utilized. - -### Maintainers -The following individuals are responsible for curating the list of issues, responding to pull requests, and ensuring regular releases happen. - -* [nicor88](https://github.com/nicor88) -* [Jrmyy](https://github.com/Jrmyy) -* [jessedobbelaere](https://github.com/jessedobbelaere) -* [mattiamatrix](https://github.com/mattiamatrix) -* [thenaturalist](https://github.com/thenaturalist) - -### Contributors -Thank you to all the people who have already contributed to this repository via bug reports, code, design, ideas, project management, translation, testing, etc. - -* [Tomme](https://github.com/Tomme) - Wrote the initial version. -* [Lemiffe](https://github.com/lemiffe) - Logo design. ## Resources * [Athena CREATE TABLE AS](https://docs.aws.amazon.com/athena/latest/ug/create-table-as.html) * [dbt-labs/dbt-core](https://github.com/dbt-labs/dbt-core) * [laughingman7743/PyAthena](https://github.com/laughingman7743/PyAthena) + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): From 8c850a9bb1c0f9a528863b00969ab982f2b300c6 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 8 Apr 2023 18:04:23 +0100 Subject: [PATCH 20/75] docs: add Tomme as a contributor for maintenance (#214) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 1423ee17..4dae74d3 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -43,6 +43,16 @@ "contributions": [ "maintenance" ] + }, + { + "login": "Tomme", + "name": "Tom", + "avatar_url": "https://avatars.githubusercontent.com/u/932895?v=4", + "profile": "https://github.com/Tomme", + "contributions": [ + "maintenance", + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 0635a8a5..65e9c80f 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Jesse Dobbelaere
    Jesse Dobbelaere

    🐛 🚧 Lemiffe
    Lemiffe

    🎨 Jérémy Guiselin
    Jérémy Guiselin

    🚧 + Tom
    Tom

    🚧 💻 From 0f50a014d8d0b27fcd83b86f51887c766eb1506f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 8 Apr 2023 18:11:11 +0100 Subject: [PATCH 21/75] docs: add mattiamatrix as a contributor for maintenance (#216) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 4dae74d3..64dbdba5 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -53,6 +53,15 @@ "maintenance", "code" ] + }, + { + "login": "mattiamatrix", + "name": "Mattia", + "avatar_url": "https://avatars.githubusercontent.com/u/5013654?v=4", + "profile": "https://github.com/mattiamatrix", + "contributions": [ + "maintenance" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 65e9c80f..c4740c17 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Lemiffe
    Lemiffe

    🎨 Jérémy Guiselin
    Jérémy Guiselin

    🚧 Tom
    Tom

    🚧 💻 + Mattia
    Mattia

    🚧 From e73ef901205b60c74c3b98c34c73d017489f4b91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 20:11:29 +0200 Subject: [PATCH 22/75] chore: Update pytest requirement from ~=7.2 to ~=7.3 (#218) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index d5226c01..15a7a88e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -7,6 +7,6 @@ isort~=5.11 moto~=4.1.6 pre-commit~=2.21 pyparsing~=3.0.9 -pytest~=7.2 +pytest~=7.3 pytest-cov~=4.0 pyupgrade~=3.3 From 8f17ea98e1dd22930376d2dc3ed26d8d9606473b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 09:27:26 +0200 Subject: [PATCH 23/75] chore: Update pyathena requirement from ~=2.23 to ~=2.24 (#217) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: nicor88 <6278547+nicor88@users.noreply.github.com> Co-authored-by: Jeremy Guiselin --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e9791850..6e5c874f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ boto3~=1.26 dbt-core~=1.4.5 -pyathena~=2.23 +pyathena~=2.24 tenacity~=8.2 diff --git a/setup.py b/setup.py index 1a79baa8..0e2cf9b6 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def _get_package_version(): # In order to control dbt-core version and package version "boto3~=1.26", "dbt-core~=1.4.5", - "pyathena~=2.23", + "pyathena~=2.24", "tenacity~=8.2", ], ) From e461f0a52601f225dc6209044a4343d653a9d7a1 Mon Sep 17 00:00:00 2001 From: Jesse Dobbelaere Date: Fri, 14 Apr 2023 10:20:30 +0200 Subject: [PATCH 24/75] fix: broken drop view query (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Guiselin <9251353+Jrmyy@users.noreply.github.com> --- dbt/include/athena/macros/adapters/relation.sql | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dbt/include/athena/macros/adapters/relation.sql b/dbt/include/athena/macros/adapters/relation.sql index cc1e7bcb..5eb5559f 100644 --- a/dbt/include/athena/macros/adapters/relation.sql +++ b/dbt/include/athena/macros/adapters/relation.sql @@ -5,7 +5,11 @@ {%- do adapter.clean_up_table(relation.schema, relation.table) -%} {%- endif %} {% call statement('drop_relation', auto_begin=False) -%} - drop {{ relation.type }} if exists {{ relation.render_hive() }} + {%- if relation.type == 'view' -%} + drop {{ relation.type }} if exists {{ relation.render() }} + {%- else -%} + drop {{ relation.type }} if exists {{ relation.render_hive() }} + {% endif %} {%- endcall %} {%- endif %} {% endmacro %} From 386ec47cb11b32b0f51d4ceb86e9415e35450b03 Mon Sep 17 00:00:00 2001 From: Julian Steger <108534789+juliansteger-sc@users.noreply.github.com> Date: Fri, 14 Apr 2023 10:46:18 +0200 Subject: [PATCH 25/75] fix: allow to set table location when output location is configured but not enforced (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Guiselin <9251353+Jrmyy@users.noreply.github.com> --- dbt/adapters/athena/impl.py | 14 +++++++++++--- .../models/table/create_table_as.sql | 6 ++++-- tests/unit/test_adapter.py | 17 ++++++++++++----- tests/unit/utils.py | 19 ++++++++++++++++++- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index e42b6f16..c66a6f62 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -152,7 +152,7 @@ def add_lf_tags( logger.debug(self.parse_lf_response(response, database, table, columns, {tag_key: tag_value})) @available - def get_work_group_output_location(self) -> Optional[str]: + def is_work_group_output_location_enforced(self) -> bool: conn = self.connections.get_thread_connection() creds = conn.credentials client = conn.handle @@ -162,13 +162,21 @@ def get_work_group_output_location(self) -> Optional[str]: if creds.work_group: work_group = athena_client.get_work_group(WorkGroup=creds.work_group) - return ( + output_location = ( work_group.get("WorkGroup", {}) .get("Configuration", {}) .get("ResultConfiguration", {}) - .get("OutputLocation") + .get("OutputLocation", None) ) + output_location_enforced = ( + work_group.get("WorkGroup", {}).get("Configuration", {}).get("EnforceWorkGroupConfiguration", False) + ) + + return output_location is not None and output_location_enforced + else: + return False + @available def s3_table_prefix(self, s3_data_dir: Optional[str]) -> str: """ diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 5205a14b..28f06f21 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -14,7 +14,7 @@ {%- set location_property = 'external_location' -%} {%- set partition_property = 'partitioned_by' -%} - {%- set work_group_output_location = adapter.get_work_group_output_location() -%} + {%- set work_group_output_location_enforced = adapter.is_work_group_output_location_enforced() -%} {%- set location = adapter.s3_table_location(s3_data_dir, s3_data_naming, relation.schema, relation.identifier, external_location, temporary) -%} {%- if materialized == 'table_hive_ha' -%} @@ -48,7 +48,9 @@ with ( table_type='{{ table_type }}', is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, - {{ location_property }}='{{ location }}', + {%- if not work_group_output_location_enforced -%} + {{ location_property }}='{{ location }}', + {%- endif %} {%- if partitioned_by is not none %} {{ partition_property }}=ARRAY{{ partitioned_by | join("', '") | replace('"', "'") | prepend("'") | append("'") }}, {%- endif %} diff --git a/tests/unit/test_adapter.py b/tests/unit/test_adapter.py index d69bdc69..68c592ea 100644 --- a/tests/unit/test_adapter.py +++ b/tests/unit/test_adapter.py @@ -723,16 +723,23 @@ def test_upload_seed_to_s3_external_location(self, aws_credentials): @mock_athena def test_get_work_group_output_location(self, aws_credentials): self.adapter.acquire_connection("dummy") - self.mock_aws_service.create_work_group_with_output_location(ATHENA_WORKGROUP) - work_group_location = self.adapter.get_work_group_output_location() - assert work_group_location is not None + self.mock_aws_service.create_work_group_with_output_location_enforced(ATHENA_WORKGROUP) + work_group_location_enforced = self.adapter.is_work_group_output_location_enforced() + assert work_group_location_enforced @mock_athena def test_get_work_group_output_location_no_location(self, aws_credentials): self.adapter.acquire_connection("dummy") self.mock_aws_service.create_work_group_no_output_location(ATHENA_WORKGROUP) - work_group_location = self.adapter.get_work_group_output_location() - assert work_group_location is None + work_group_location_enforced = self.adapter.is_work_group_output_location_enforced() + assert not work_group_location_enforced + + @mock_athena + def test_get_work_group_output_location_not_enforced(self, aws_credentials): + self.adapter.acquire_connection("dummy") + self.mock_aws_service.create_work_group_with_output_location_not_enforced(ATHENA_WORKGROUP) + work_group_location_enforced = self.adapter.is_work_group_output_location_enforced() + assert not work_group_location_enforced @mock_athena @mock_glue diff --git a/tests/unit/utils.py b/tests/unit/utils.py index 70fa3aac..9607b432 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -291,7 +291,7 @@ def create_table_without_table_type(self, table_name: str): }, ) - def create_work_group_with_output_location(self, work_group_name: str): + def create_work_group_with_output_location_enforced(self, work_group_name: str): athena = boto3.client("athena", region_name=AWS_REGION) athena.create_work_group( Name=work_group_name, @@ -308,6 +308,23 @@ def create_work_group_with_output_location(self, work_group_name: str): }, ) + def create_work_group_with_output_location_not_enforced(self, work_group_name: str): + athena = boto3.client("athena", region_name=AWS_REGION) + athena.create_work_group( + Name=work_group_name, + Configuration={ + "ResultConfiguration": { + "OutputLocation": "s3://pre-configured-output-location/", + }, + "EnforceWorkGroupConfiguration": False, + "PublishCloudWatchMetricsEnabled": True, + "EngineVersion": { + "SelectedEngineVersion": "Athena engine version 2", + "EffectiveEngineVersion": "Athena engine version 2", + }, + }, + ) + def create_work_group_no_output_location(self, work_group_name: str): athena = boto3.client("athena", region_name=AWS_REGION) athena.create_work_group( From 067d66f7ac7019d33cb7045408812729776809cc Mon Sep 17 00:00:00 2001 From: CommonCrisis <44769652+CommonCrisis@users.noreply.github.com> Date: Fri, 14 Apr 2023 14:52:11 +0200 Subject: [PATCH 26/75] fix: reading README.md (#225) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0e2cf9b6..879fbaa5 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ this_directory = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(this_directory, "README.md")) as f: +with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f: long_description = f.read() package_name = "dbt-athena-community" From 1beb024cea23838ea5cb10a7339df8ed3c8f2184 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 01:06:17 -0300 Subject: [PATCH 27/75] Added tests --- dbt/adapters/athena/python_submissions.py | 63 ++++---- tests/conftest.py | 34 +---- tests/unit/test_python_submissions.py | 167 ++++++++++++++++++++-- 3 files changed, 193 insertions(+), 71 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index d1ba83dc..9433aff1 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -47,14 +47,11 @@ class AthenaPythonJobHelper(PythonJobHelper): """ def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: - self.identifier = parsed_model["alias"] - self.schema = parsed_model["schema"] self.parsed_model = parsed_model self.timeout = self.get_timeout() self.polling_interval = DEFAULT_POLLING_INTERVAL - self.region_name = credentials.region_name - self.profile_name = credentials.aws_profile_name - self.spark_work_group = credentials.spark_work_group + self.credentials = credentials + self.athena_client = self.athena_client() @property @lru_cache() @@ -75,7 +72,17 @@ def session_id(self) -> str: return session_info.get("SessionId") @property - @lru_cache() + def identifier(self) -> str: + return self.parsed_model["alias"] + + @property + def schema(self) -> str: + return self.parsed_model["schema"] + + @property + def spark_work_group(self) -> str: + return self.credentials.spark_work_group + def athena_client(self) -> Any: """ Get the AWS Athena client. @@ -87,7 +94,9 @@ def athena_client(self) -> Any: Any: The Athena client object. """ - return boto3.session.Session(region_name=self.region_name, profile_name=self.profile_name).client("athena") + return boto3.session.Session( + region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name + ).client("athena") def get_timeout(self) -> int: """ @@ -104,7 +113,10 @@ def get_timeout(self) -> int: ValueError: If the timeout value is not a positive integer. """ - timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) + if self.parsed_model.get("config") is not None: + timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) + else: + timeout = DEFAULT_TIMEOUT if timeout <= 0: raise ValueError("Timeout must be a positive integer") return timeout @@ -166,19 +178,22 @@ def submit(self, compiled_code: str) -> dict: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ - calculation_execution_id = self.athena_client.start_calculation_execution( - SessionId=self.session_id, CodeBlock=compiled_code.lstrip() - )["CalculationExecutionId"] - logger.debug(f"Submitted calculation execution id {calculation_execution_id}") - execution_status = self._poll_until_execution_completion(calculation_execution_id) - logger.debug(f"Received execution status {execution_status}") - if execution_status == "COMPLETED": - result_s3_uri = self.athena_client.get_calculation_execution( - CalculationExecutionId=calculation_execution_id - )["Result"]["ResultS3Uri"] - return result_s3_uri - else: - raise DbtRuntimeError(f"python model run ended in state {execution_status}") + try: + calculation_execution_id = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.lstrip() + )["CalculationExecutionId"] + logger.debug(f"Submitted calculation execution id {calculation_execution_id}") + execution_status = self._poll_until_execution_completion(calculation_execution_id) + logger.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + result_s3_uri = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"]["ResultS3Uri"] + return result_s3_uri + else: + raise DbtRuntimeError(f"python model run ended in state {execution_status}") + finally: + self._terminate_session() def _terminate_session(self) -> dict: """ @@ -198,7 +213,7 @@ def _terminate_session(self) -> dict: session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) ): logger.debug(f"Terminating session: {self.session_id}") - self.athena_client.terminate_session(SessionId=self.session_id) + return self.athena_client.terminate_session(SessionId=self.session_id) def _poll_until_execution_completion(self, calculation_execution_id): """ @@ -261,7 +276,3 @@ def _poll_until_session_creation(self, session_id): polling_interval *= 2 if polling_interval > self.timeout: raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") - - def __del__(self) -> None: - """Teardown for the class.""" - self._terminate_session() diff --git a/tests/conftest.py b/tests/conftest.py index 4dd49304..4aad8588 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,6 @@ import dbt from dbt.adapters.athena.connections import AthenaCredentials -from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper from dbt.events.base_types import EventLevel from dbt.events.eventmgr import NoFilter from dbt.events.functions import EVENT_MANAGER, _get_stdout_config @@ -65,24 +64,9 @@ def _setup_custom_caplog(name: str, level: EventLevel): @pytest.fixture(scope="class") -def start_session_response(request): - return request - - -@pytest.mark.parametrize( - "start_session_response", - [ - ({"SessionId": "test_session_id", "State": "CREATING"}), - ({"SessionId": "test_session_id", "State": "IDLE"}), - ({"SessionId": "test_session_id", "State": "TERMINATED"}), - ], - indirect=True, -) -@pytest.fixture(scope="class") -def athena_client(start_session_response): - with patch.object(boto3.session.Session, "client", return_value=MagicMock()) as mock_client: - mock_client.start_session.return_value = start_session_response - yield mock_client +def athena_client(): + with patch.object(boto3.session.Session, "client", return_value=MagicMock()) as mock_athena_client: + return mock_athena_client @patch.object(dbt.adapters.athena.connections, "AthenaCredentials") @@ -96,15 +80,3 @@ def athena_credentials(): work_group=ATHENA_WORKGROUP, spark_work_group=SPARK_WORKGROUP, ) - - -# @pytest.mark.parametrize( -# "parsed_model", [({"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}})], indirect=True -# ) -@pytest.fixture(scope="class") -def athena_python_job_helper(athena_client, athena_credentials): - parsed_model = {"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}} - with patch.object(AthenaPythonJobHelper, "athena_client", return_value=athena_client): - assert athena_credentials.spark_work_group == "spark" - athena_job_helper = AthenaPythonJobHelper(parsed_model, athena_credentials) - yield athena_job_helper diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 38e01b8e..38d07b7e 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,27 +1,166 @@ +from datetime import datetime from unittest.mock import patch import pytest +import pytz +from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper from dbt.exceptions import DbtRuntimeError +from .constants import DATABASE_NAME -@pytest.mark.usefixtures("athena_python_job_helper") + +@pytest.mark.usefixtures("athena_credentials", "athena_client") class TestPythonSubmission: + """ + A class to test the AthenaPythonJobHelper + """ + + @pytest.fixture() + def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): + parsed_model = {"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}} + mock_job_helper = AthenaPythonJobHelper(parsed_model, athena_credentials) + monkeypatch.setattr(mock_job_helper, "athena_client", athena_client) + return mock_job_helper + + @pytest.mark.parametrize( + "session_status_response, expected_response", + [ + pytest.param( + {"Status": {"SessionId": "test_session_id", "State": "CREATING"}}, + DbtRuntimeError( + """Session + did not create within 10 seconds.""" + ), + marks=pytest.mark.xfail, + ), + ( + {"Status": {"SessionId": "test_session_id", "State": "IDLE"}}, + {"SessionId": "test_session_id", "State": "IDLE"}, + ), + pytest.param( + {"Status": {"SessionId": "test_session_id", "State": "TERMINATED"}}, + DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."), + marks=pytest.mark.xfail, + ), + ], + ) + def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client): + with ( + patch.object(athena_job_helper, "_poll_until_execution_completion", return_value=session_status_response), + patch.object(athena_client, "get_session_status", return_value=session_status_response), + patch.object(athena_client, "start_session", return_value=session_status_response.get("Status")), + ): + response = athena_job_helper._start_session() + assert response == expected_response + + @pytest.mark.parametrize( + "session_status_response, expected_response", + [ + pytest.param( + { + "NextToken": "test_token", + "Sessions": [ + { + "Description": "string", + "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, + "NotebookVersion": "string", + "SessionId": "string", + "Status": { + "EndDateTime": "number", + "IdleSinceDateTime": "number", + "LastModifiedDateTime": "number", + "StartDateTime": "number", + "State": "IDLE", + "StateChangeReason": "string", + }, + } + ], + }, + { + "Description": "string", + "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, + "NotebookVersion": "string", + "SessionId": "string", + "Status": { + "EndDateTime": "number", + "IdleSinceDateTime": "number", + "LastModifiedDateTime": "number", + "StartDateTime": "number", + "State": "IDLE", + "StateChangeReason": "string", + }, + }, + ), + ( + { + "NextToken": "string", + "Sessions": [], + }, + None, + ), + ], + ) + def test_list_sessions(self, session_status_response, expected_response, athena_job_helper, athena_client): + with (patch.object(athena_client, "list_sessions", return_value=session_status_response),): + response = athena_job_helper._list_sessions() + assert response == expected_response + + @pytest.mark.parametrize( + "parsed_models, expected_timeout", + [ + ({"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}}, 10), + pytest.param( + {"alias": "test_model", "schema": "test_database", "config": {"timeout": 0}}, 0, marks=pytest.mark.xfail + ), + ({"alias": "test_model", "schema": DATABASE_NAME}, 7200), + ], + ) + def test_get_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch): + monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) + response = athena_job_helper.get_timeout() + assert response == expected_timeout + @pytest.mark.parametrize( - "expected_response", + "session_status_response, expected_response", [ - (None), - ("test_session_id"), - (DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."),), + ( + { + "SessionId": "string", + "Status": { + "EndDateTime": "number", + "IdleSinceDateTime": "number", + "LastModifiedDateTime": "number", + "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), + "State": "IDLE", + "StateChangeReason": "string", + }, + }, + {"State": "TERMINATED"}, + ), + ( + { + "SessionId": "string", + "Status": { + "EndDateTime": "number", + "IdleSinceDateTime": "number", + "LastModifiedDateTime": "number", + "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), + "State": "TERMINATED", + "StateChangeReason": "string", + }, + }, + None, + ), ], ) - def test_start_session(self, expected_response, athena_python_job_helper): - athena_python_job_helper.timeout = 0 - with patch.object( - athena_python_job_helper, - "_poll_until_session_creation", - return_value=expected_response, + def test_terminate_session( + self, session_status_response, expected_response, athena_job_helper, athena_client, monkeypatch + ): + monkeypatch.setattr(athena_job_helper, "timeout", 10) + with ( + patch.object(athena_client, "get_session_status", return_value=session_status_response), + patch.object(athena_client, "terminate_session", return_value=expected_response), ): - assert athena_python_job_helper._start_session().get("SessionId") == expected_response - athena_python_job_helper._start_session.assert_called_once() - athena_python_job_helper._poll_until_session_creation.assert_called_once() + terminate_session_response = athena_job_helper._terminate_session() + assert terminate_session_response == expected_response From 84c9d50711e0891ce96c0ef7d1525509a80d48bf Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 13:18:09 -0300 Subject: [PATCH 28/75] Added more tests --- dbt/adapters/athena/python_submissions.py | 70 ++++++++++++++++----- tests/unit/test_python_submissions.py | 77 ++++++++++++++++++++--- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 9433aff1..4852c792 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -10,7 +10,8 @@ from dbt.events import AdapterLogger from dbt.exceptions import DbtRuntimeError -DEFAULT_POLLING_INTERVAL = 2 +DEFAULT_POLLING_INTERVAL = 5 +DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} SUBMISSION_LANGUAGE = "python" DEFAULT_TIMEOUT = 60 * 60 * 2 @@ -48,14 +49,23 @@ class AthenaPythonJobHelper(PythonJobHelper): def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.parsed_model = parsed_model - self.timeout = self.get_timeout() - self.polling_interval = DEFAULT_POLLING_INTERVAL self.credentials = credentials self.athena_client = self.athena_client() + @property + def identifier(self) -> str: + return self.parsed_model["alias"] + + @property + def schema(self) -> str: + return self.parsed_model["schema"] + @property @lru_cache() def session_id(self) -> str: + return self._set_session_id() + + def _set_session_id(self) -> str: """ Get the session ID. @@ -71,14 +81,6 @@ def session_id(self) -> str: return self._start_session().get("SessionId") return session_info.get("SessionId") - @property - def identifier(self) -> str: - return self.parsed_model["alias"] - - @property - def schema(self) -> str: - return self.parsed_model["schema"] - @property def spark_work_group(self) -> str: return self.credentials.spark_work_group @@ -98,7 +100,11 @@ def athena_client(self) -> Any: region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name ).client("athena") - def get_timeout(self) -> int: + @property + def timeout(self) -> int: + return self._set_timeout() + + def _set_timeout(self) -> int: """ Get the timeout value. @@ -115,12 +121,42 @@ def get_timeout(self) -> int: """ if self.parsed_model.get("config") is not None: timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) + if not isinstance(timeout, int): + raise TypeError("Timeout must be an integer") + if timeout <= 0: + raise ValueError("Timeout must be a positive integer") + logger.info(f"Setting timeout: {timeout}") else: + logger.info(f"Using default timeout: {DEFAULT_TIMEOUT}") timeout = DEFAULT_TIMEOUT - if timeout <= 0: - raise ValueError("Timeout must be a positive integer") return timeout + @property + def polling_interval(self): + return self._set_polling_interval() + + def _set_polling_interval(self) -> int: + polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULT_POLLING_INTERVAL) + if not isinstance(polling_interval, int) or polling_interval <= 0: + raise ValueError("polling_interval must be a positive integer") + logger.info(f"Setting polling_interval: {polling_interval}") + return polling_interval + + @property + def engine_config(self): + return self._set_engine_config() + + def _set_engine_config(self) -> dict: + engine_config = self.parsed_model.get("config", {}).get("engine_config", DEFAULT_ENGINE_CONFIG) + if not isinstance(engine_config, dict): + raise TypeError("engine configuration has to be of type dict") + + expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} + if set(engine_config.keys()) != expected_keys: + raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") + + return engine_config + def _list_sessions(self) -> dict: """ List Athena sessions. @@ -152,7 +188,7 @@ def _start_session(self) -> dict: """ response = self.athena_client.start_session( WorkGroup=self.spark_work_group, - EngineConfiguration={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + EngineConfiguration=self.engine_config, ) if response["State"] != "IDLE": self._poll_until_session_creation(response["SessionId"]) @@ -195,7 +231,7 @@ def submit(self, compiled_code: str) -> dict: finally: self._terminate_session() - def _terminate_session(self) -> dict: + def _terminate_session(self) -> None: """ Terminate the current Athena session. @@ -213,7 +249,7 @@ def _terminate_session(self) -> dict: session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) ): logger.debug(f"Terminating session: {self.session_id}") - return self.athena_client.terminate_session(SessionId=self.session_id) + self.athena_client.terminate_session(SessionId=self.session_id) def _poll_until_execution_completion(self, calculation_execution_id): """ diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 38d07b7e..3e713a35 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -47,7 +47,7 @@ def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): ) def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client): with ( - patch.object(athena_job_helper, "_poll_until_execution_completion", return_value=session_status_response), + patch.object(athena_job_helper, "_poll_until_session_creation", return_value=session_status_response), patch.object(athena_client, "get_session_status", return_value=session_status_response), patch.object(athena_client, "start_session", return_value=session_status_response.get("Status")), ): @@ -116,17 +116,73 @@ def test_list_sessions(self, session_status_response, expected_response, athena_ ({"alias": "test_model", "schema": DATABASE_NAME}, 7200), ], ) - def test_get_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch): + def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch): monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - response = athena_job_helper.get_timeout() + response = athena_job_helper._set_timeout() assert response == expected_timeout @pytest.mark.parametrize( - "session_status_response, expected_response", + "parsed_models, expected_polling_interval", + [ + ({"alias": "test_model", "schema": DATABASE_NAME, "config": {"polling_interval": 10}}, 10), + pytest.param( + {"alias": "test_model", "schema": "test_database", "config": {"timeout": 0}}, 0, marks=pytest.mark.xfail + ), + ({"alias": "test_model", "schema": DATABASE_NAME}, 5), + ], + ) + def test_set_polling_interval(self, parsed_models, expected_polling_interval, athena_job_helper, monkeypatch): + monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) + response = athena_job_helper._set_polling_interval() + assert response == expected_polling_interval + + @pytest.mark.parametrize( + "parsed_models, expected_engine_config", [ ( { - "SessionId": "string", + "alias": "test_model", + "schema": DATABASE_NAME, + "config": { + "engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 44, "DefaultExecutorDpuSize": 1} + }, + }, + {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 44, "DefaultExecutorDpuSize": 1}, + ), + pytest.param( + {"alias": "test_model", "schema": "test_database", "config": {"engine_config": 0}}, + 0, + marks=pytest.mark.xfail, + ), + pytest.param( + { + "alias": "test_wrong_model", + "schema": "test_database", + "config": {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2}}, + }, + (), + marks=pytest.mark.xfail, + ), + ( + {"alias": "test_model", "schema": DATABASE_NAME}, + {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + ), + ], + ) + def test_set_engine_config(self, parsed_models, expected_engine_config, athena_job_helper, monkeypatch): + monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) + if parsed_models.get("alias") == "test_wrong_model": + with pytest.raises(KeyError): + athena_job_helper._set_engine_config() + response = athena_job_helper._set_engine_config() + assert response == expected_engine_config + + @pytest.mark.parametrize( + "session_status_response, test_session_id, expected_response", + [ + ( + { + "SessionId": "test_session_id", "Status": { "EndDateTime": "number", "IdleSinceDateTime": "number", @@ -136,11 +192,12 @@ def test_get_timeout(self, parsed_models, expected_timeout, athena_job_helper, m "StateChangeReason": "string", }, }, - {"State": "TERMINATED"}, + "test_session_id", + None, ), ( { - "SessionId": "string", + "SessionId": "test_session_id_2", "Status": { "EndDateTime": "number", "IdleSinceDateTime": "number", @@ -150,17 +207,19 @@ def test_get_timeout(self, parsed_models, expected_timeout, athena_job_helper, m "StateChangeReason": "string", }, }, + "test_session_id_2", None, ), ], ) def test_terminate_session( - self, session_status_response, expected_response, athena_job_helper, athena_client, monkeypatch + self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch ): - monkeypatch.setattr(athena_job_helper, "timeout", 10) with ( patch.object(athena_client, "get_session_status", return_value=session_status_response), patch.object(athena_client, "terminate_session", return_value=expected_response), + patch.object(athena_job_helper, "_set_session_id", return_value=test_session_id), + patch.object(athena_job_helper, "_set_timeout", return_value=10), ): terminate_session_response = athena_job_helper._terminate_session() assert terminate_session_response == expected_response From 0693fa96341d68c864c9c47718754439cd7fe21b Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 18:47:34 -0300 Subject: [PATCH 29/75] Fixed mypy errors --- dbt/adapters/athena/python_submissions.py | 14 +++++++------- tests/unit/test_python_submissions.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 4852c792..88490ff8 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -50,7 +50,7 @@ class AthenaPythonJobHelper(PythonJobHelper): def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.parsed_model = parsed_model self.credentials = credentials - self.athena_client = self.athena_client() + self.athena_client = self.get_athena_client() @property def identifier(self) -> str: @@ -77,15 +77,15 @@ def _set_session_id(self) -> str: """ session_info = self._list_sessions() - if session_info is None: - return self._start_session().get("SessionId") - return session_info.get("SessionId") + if session_info.get("SessionId") is None: + return self._start_session()["SessionId"] + return session_info["SessionId"] @property def spark_work_group(self) -> str: - return self.credentials.spark_work_group + return self.credentials.spark_work_group or "spark" - def athena_client(self) -> Any: + def get_athena_client(self) -> Any: """ Get the AWS Athena client. @@ -171,7 +171,7 @@ def _list_sessions(self) -> dict: """ response = self.athena_client.list_sessions(WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE") if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: - return None + return {} return response.get("Sessions")[0] def _start_session(self) -> dict: diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 3e713a35..861dd679 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -97,7 +97,7 @@ def test_start_session(self, session_status_response, expected_response, athena_ "NextToken": "string", "Sessions": [], }, - None, + {}, ), ], ) @@ -223,3 +223,12 @@ def test_terminate_session( ): terminate_session_response = athena_job_helper._terminate_session() assert terminate_session_response == expected_response + + def test_poll_session_creation(self): + pass + + def test_poll_execution(self): + pass + + def test_submission(self): + pass \ No newline at end of file From eac05049e1247254410a2bafc796dded444c5539 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 18:48:29 -0300 Subject: [PATCH 30/75] Formatting --- tests/unit/test_python_submissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 861dd679..f6d3a258 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -231,4 +231,4 @@ def test_poll_execution(self): pass def test_submission(self): - pass \ No newline at end of file + pass From 589715e84c84c0ac7b7e86639da0e5e29171ff31 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 19:09:00 -0300 Subject: [PATCH 31/75] Simplified patches --- tests/unit/test_python_submissions.py | 28 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index f6d3a258..d9b13081 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest import pytz @@ -46,10 +46,13 @@ def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): ], ) def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client): - with ( - patch.object(athena_job_helper, "_poll_until_session_creation", return_value=session_status_response), - patch.object(athena_client, "get_session_status", return_value=session_status_response), - patch.object(athena_client, "start_session", return_value=session_status_response.get("Status")), + with patch.multiple( + athena_job_helper, + _poll_until_session_creation=Mock(return_value=session_status_response), + ), patch.multiple( + athena_client, + get_session_status=Mock(return_value=session_status_response), + start_session=Mock(return_value=session_status_response.get("Status")), ): response = athena_job_helper._start_session() assert response == expected_response @@ -102,7 +105,7 @@ def test_start_session(self, session_status_response, expected_response, athena_ ], ) def test_list_sessions(self, session_status_response, expected_response, athena_job_helper, athena_client): - with (patch.object(athena_client, "list_sessions", return_value=session_status_response),): + with patch.object(athena_client, "list_sessions", return_value=session_status_response): response = athena_job_helper._list_sessions() assert response == expected_response @@ -215,11 +218,14 @@ def test_set_engine_config(self, parsed_models, expected_engine_config, athena_j def test_terminate_session( self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch ): - with ( - patch.object(athena_client, "get_session_status", return_value=session_status_response), - patch.object(athena_client, "terminate_session", return_value=expected_response), - patch.object(athena_job_helper, "_set_session_id", return_value=test_session_id), - patch.object(athena_job_helper, "_set_timeout", return_value=10), + with patch.multiple( + athena_client, + get_session_status=Mock(return_value=session_status_response), + terminate_session=Mock(return_value=expected_response), + ), patch.multiple( + athena_job_helper, + _set_session_id=Mock(return_value=test_session_id), + _set_timeout=Mock(return_value=10), ): terminate_session_response = athena_job_helper._terminate_session() assert terminate_session_response == expected_response From 4c43d49f4b4218e4cc4050db0cda07606ad8000f Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 21 Apr 2023 19:10:17 -0300 Subject: [PATCH 32/75] Fixed imports --- tests/unit/test_python_submissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index d9b13081..702aa348 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import pytest import pytz From de12fd25357008b81af06db1317aad44c378e3b5 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 23 Apr 2023 00:10:21 -0300 Subject: [PATCH 33/75] Added docs to tests and added kwargs --- .../macros/adapters/python_submissions.sql | 34 +++++- .../models/table/create_table_as.sql | 17 ++- tests/unit/test_python_submissions.py | 105 ++++++++++++++++-- 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index c9c02d24..6283e536 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -1,14 +1,42 @@ -{%- macro athena__py_save_table_as(compiled_code, target_relation, format, location, mode="overwrite") -%} +{%- macro athena__py_save_table_as(compiled_code, target_relation , **kwargs) -%} + + {% set location = kwargs.get("location") %} + {% set format = kwargs.get("format", "parquet") %} + {% set mode = kwargs.get("mode", "overwrite") %} + {% set write_compression = kwargs.get("write_compression", "snappy") %} + {% set partitioned_by = kwargs.get("partitioned_by") %} + {% set bucketed_by = kwargs.get("bucketed_by") %} + {% set sorted_by = kwargs.get("sorted_by") %} + {% set merge_schema = kwargs.get("merge_schema", true) %} + {% set bucket_count = kwargs.get("bucket_count") %} + {% set field_delimiter = kwargs.get("field_delimiter") %} + {% set table_type = kwargs.get("table_type") %} + {% set extra_table_properties = kwargs.get("extra_table_properties") %} + {{ compiled_code }} def materialize(spark_session, df, target_relation): import pandas try: - if isinstance(df, pandas.core.frame.DataFrame): + if isinstance(df, pyspark.sql.dataframe.DataFrame): + pass + elif isinstance(df, pandas.core.frame.DataFrame): df = spark_session.createDataFrame(df) + else: + msg = f"{type(df)} is not a supported type for dbt Python materialization" + raise Exception(msg) df.write \ .format("{{ format }}") \ - .option("path", "{{ location }}") \ .mode("{{ mode }}") \ + .partitionBy("{{ partitioned_by }}") \ + .bucketBy("{{ bucketed_by }}") \ + .sortBy(" {{ sorted_by }}") \ + .option("path", "{{ location }}") \ + .option("compression", "{{ write_compression }}") \ + .option("mergeSchema", "{{ merge_schema }}") \ + .option("numBuckets", "{{ bucket_count }}") \ + .option("delimiter", "{{ field_delimiter }}") \ + .option("tableType", "{{ table_type }}") \ + .option("extra", "{{ extra_table_properties }}") \ .saveAsTable( name="{{ target_relation.schema}}.{{ target_relation.identifier }}", ) diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 969db5e7..91b0b538 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -77,7 +77,22 @@ as {{ compiled_code }} {%- elif language == 'python' -%} - {{ athena__py_save_table_as(compiled_code=compiled_code, target_relation=relation, format=format, location=location, mode="overwrite") }} + {{ + athena__py_save_table_as( + compiled_code, + target_relation, + location=location, + format=format, + mode="overwrite", + partitioned_by=partitioned_by, + bucketed_by=bucketed_by, + write_compression=write_compression, + bucket_count=bucket_count, + field_delimiter=field_delimiter, + table_type=table_type, + extra_table_properties=extra_table_properties + ) + }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 702aa348..406ad05b 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -45,7 +45,20 @@ def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): ), ], ) - def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client): + def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: + """ + Test the _start_session method of the AthenaJobHelper class. + + Args: + session_status_response (dict): A dictionary containing the response from the Athena session + creation status. + expected_response (Union[dict, DbtRuntimeError]): The expected response from the _start_session method. + athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. + athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. + + Returns: + None + """ with patch.multiple( athena_job_helper, _poll_until_session_creation=Mock(return_value=session_status_response), @@ -104,7 +117,22 @@ def test_start_session(self, session_status_response, expected_response, athena_ ), ], ) - def test_list_sessions(self, session_status_response, expected_response, athena_job_helper, athena_client): + def test_list_sessions(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: + """ + Test the _list_sessions method of the AthenaJobHelper class. + + Args: + session_status_response (dict): The response object to be returned by the mock Athena client. + expected_response (dict): The expected output of the _list_sessions method. + athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. + athena_client (Mock): A mock instance of the Athena client. + + Returns: + None: This function only asserts the output of the _list_sessions method. + + Raises: + AssertionError: If the output of the _list_sessions method does not match the expected output. + """ with patch.object(athena_client, "list_sessions", return_value=session_status_response): response = athena_job_helper._list_sessions() assert response == expected_response @@ -119,7 +147,20 @@ def test_list_sessions(self, session_status_response, expected_response, athena_ ({"alias": "test_model", "schema": DATABASE_NAME}, 7200), ], ) - def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch): + def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch) -> None: + """ + Test case to verify that the `_set_timeout` method of the `AthenaPythonJobHelper` class + returns the expected timeout value. + + Args: + parsed_models (dict): A dictionary containing the parsed model configuration. + expected_timeout (int): The expected timeout value. + athena_job_helper (AthenaPythonJobHelper): An instance of the `AthenaPythonJobHelper` class. + monkeypatch: A pytest fixture used to mock and patch objects in the test environment. + + Returns: + None + """ monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) response = athena_job_helper._set_timeout() assert response == expected_timeout @@ -129,12 +170,31 @@ def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, m [ ({"alias": "test_model", "schema": DATABASE_NAME, "config": {"polling_interval": 10}}, 10), pytest.param( - {"alias": "test_model", "schema": "test_database", "config": {"timeout": 0}}, 0, marks=pytest.mark.xfail + {"alias": "test_model", "schema": "test_database", "config": {"polling_interval": 0}}, + 0, + marks=pytest.mark.xfail, ), ({"alias": "test_model", "schema": DATABASE_NAME}, 5), ], ) - def test_set_polling_interval(self, parsed_models, expected_polling_interval, athena_job_helper, monkeypatch): + def test_set_polling_interval( + self, parsed_models, expected_polling_interval, athena_job_helper, monkeypatch + ) -> None: + """ + Test method to verify that _set_polling_interval() method of AthenaPythonJobHelper + sets the correct polling interval value based on the parsed model configuration. + + Args: + parsed_models (dict): Dictionary containing the parsed configuration model for the Athena job. + expected_polling_interval (int): The expected polling interval value based on the + parsed model configuration. + athena_job_helper (AthenaPythonJobHelper): The instance of AthenaPythonJobHelper to be tested. + monkeypatch: A pytest monkeypatch fixture used to override the parsed model configuration + in AthenaPythonJobHelper. + + Returns: + None + """ monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) response = athena_job_helper._set_polling_interval() assert response == expected_polling_interval @@ -172,7 +232,22 @@ def test_set_polling_interval(self, parsed_models, expected_polling_interval, at ), ], ) - def test_set_engine_config(self, parsed_models, expected_engine_config, athena_job_helper, monkeypatch): + def test_set_engine_config(self, parsed_models, expected_engine_config, athena_job_helper, monkeypatch) -> None: + """ + Test method to verify the `_set_engine_config()` method of the AthenaPythonJobHelper class. + + Args: + parsed_models: A dictionary containing the parsed model configuration data. + expected_engine_config: The expected engine configuration that is set. + athena_job_helper: An instance of the AthenaPythonJobHelper class. + monkeypatch: A fixture from the pytest library that allows modifying attributes at runtime for testing. + + Raises: + KeyError: If the parsed model configuration dictionary does not contain the required key. + + Returns: + None. The method asserts that the actual engine configuration set matches the expected engine configuration. + """ monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) if parsed_models.get("alias") == "test_wrong_model": with pytest.raises(KeyError): @@ -217,7 +292,23 @@ def test_set_engine_config(self, parsed_models, expected_engine_config, athena_j ) def test_terminate_session( self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch - ): + ) -> None: + """ + Test function to check if _terminate_session() method of AthenaPythonJobHelper class correctly + terminates an Athena session. + + Args: + session_status_response: A mock response object containing the current status of the Athena session. + test_session_id: The session ID of the test Athena session. + expected_response: The expected response object after the Athena session is terminated. + athena_job_helper: An instance of the AthenaPythonJobHelper class. + athena_client: The mocked Athena client object. + monkeypatch: Pytest monkeypatch object for patching objects and values during testing. + + Returns: + None + """ + with patch.multiple( athena_client, get_session_status=Mock(return_value=session_status_response), From 8e1bf6fe6975519764dc773e21e6ccffed3f4470 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 23 Apr 2023 00:17:07 -0300 Subject: [PATCH 34/75] Fixed whitespace --- .../macros/adapters/python_submissions.sql | 3 +- .../models/table/create_table_as.sql | 30 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 6283e536..7524b872 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -1,5 +1,4 @@ {%- macro athena__py_save_table_as(compiled_code, target_relation , **kwargs) -%} - {% set location = kwargs.get("location") %} {% set format = kwargs.get("format", "parquet") %} {% set mode = kwargs.get("mode", "overwrite") %} @@ -18,7 +17,7 @@ def materialize(spark_session, df, target_relation): import pandas try: if isinstance(df, pyspark.sql.dataframe.DataFrame): - pass + pass elif isinstance(df, pandas.core.frame.DataFrame): df = spark_session.createDataFrame(df) else: diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 91b0b538..e0793781 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -77,22 +77,20 @@ as {{ compiled_code }} {%- elif language == 'python' -%} - {{ - athena__py_save_table_as( - compiled_code, - target_relation, - location=location, - format=format, - mode="overwrite", - partitioned_by=partitioned_by, - bucketed_by=bucketed_by, - write_compression=write_compression, - bucket_count=bucket_count, - field_delimiter=field_delimiter, - table_type=table_type, - extra_table_properties=extra_table_properties - ) - }} + {{ athena__py_save_table_as( + compiled_code, + target_relation, + location=location, + format=format, + mode="overwrite", + partitioned_by=partitioned_by, + bucketed_by=bucketed_by, + write_compression=write_compression, + bucket_count=bucket_count, + field_delimiter=field_delimiter, + table_type=table_type, + extra_table_properties=extra_table_properties + ) }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} From 352e1f6b8a9cfa03b45807cfda4850d7d7da5d4f Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 23 Apr 2023 00:18:24 -0300 Subject: [PATCH 35/75] Fixed whitespace --- dbt/include/athena/macros/adapters/python_submissions.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 7524b872..301fc243 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -1,5 +1,5 @@ {%- macro athena__py_save_table_as(compiled_code, target_relation , **kwargs) -%} - {% set location = kwargs.get("location") %} + {% set location = kwargs.get("location") %} {% set format = kwargs.get("format", "parquet") %} {% set mode = kwargs.get("mode", "overwrite") %} {% set write_compression = kwargs.get("write_compression", "snappy") %} From 25531c98c1d13d8707a4a588546b2e8f75f24d1a Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 23 Apr 2023 16:15:53 -0300 Subject: [PATCH 36/75] Added overrides to source and ref --- .../macros/adapters/python_submissions.sql | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 301fc243..6ac83ec0 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -43,12 +43,16 @@ def materialize(spark_session, df, target_relation): except Exception: raise -dbt = dbtObj(spark.table) -df = model(dbt, spark) -materialize(spark, df, dbt.this) +{{ athena__py_get_spark_dbt_object() }} + +dbt = SparkdbtObj(spark) +df = model(dbt, dbt.spark_session) +materialize(dbt.spark_session, df, dbt.this) {%- endmacro -%} {%- macro athena__py_execute_query(query) -%} +{{ athena__py_get_spark_dbt_object() }} + def execute_query(spark_session): import pandas try: @@ -57,6 +61,46 @@ def execute_query(spark_session): except Exception: raise -dbt = dbtObj(spark.table) -execute_query(spark) +dbt = SparkdbtObj(spark) +execute_query(dbt, dbt.spark_session) {%- endmacro -%} + +{%- macro athena__py_get_spark_dbt_object() -%} +class SparkdbtObj(dbtObj, spark_session): + def __init__(self) -> None: + super().__init__(load_df_function=spark.table) + self.spark_session = spark_session + + @property + def source(self, *args): + """ + Override the source attribute dynamically + + spark.table('awsdatacatalog.analytics_dev.model') + Raises pyspark.sql.utils.AnalysisException: + spark_catalog requires a single-part namespace, + but got [awsdatacatalog, analytics_dev] + + So the override removes the catalog component and only + provides the schema and identifer to spark.table() + """ + args = [arg[arg.index('.')+1:] for arg in args] + return lambda *args: source(*args, dbt_load_df_function=spark.table) + + @property + def ref(self, *args): + """ + Override the ref attribute dynamically + + spark.table('awsdatacatalog.analytics_dev.model') + Raises pyspark.sql.utils.AnalysisException: + spark_catalog requires a single-part namespace, + but got [awsdatacatalog, analytics_dev] + + So the override removes the catalog component and only + provides the schema and identifer to spark.table() + """ + args = [arg[arg.index('.')+1:] for arg in args] + return lambda *args: ref(*args, dbt_load_df_function=spark.table) + +{%- endmacro -%} \ No newline at end of file From f86159da7cc90216394250f17bfd7525be446722 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 24 Apr 2023 00:09:46 -0300 Subject: [PATCH 37/75] Added support for configuring table output --- dbt/adapters/athena/python_submissions.py | 44 +++++-- .../macros/adapters/python_submissions.sql | 107 ++++++++---------- .../models/table/create_table_as.sql | 27 ++--- 3 files changed, 96 insertions(+), 82 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 88490ff8..93126ac6 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -193,6 +193,25 @@ def _start_session(self) -> dict: if response["State"] != "IDLE": self._poll_until_session_creation(response["SessionId"]) return response + + def _get_current_session_status(self) -> str: + """ + Get the current session status. + + Returns: + str: The status of the session + """ + return self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + + def _poll_until_session_idle(self): + polling_interval = self.polling_interval + while True: + session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + if session_status == "IDLE": + return session_status + if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: + return DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") + def submit(self, compiled_code: str) -> dict: """ @@ -214,22 +233,29 @@ def submit(self, compiled_code: str) -> dict: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ + if self._get_current_session_status() == "BUSY": + self._poll_until_session_idle() try: calculation_execution_id = self.athena_client.start_calculation_execution( SessionId=self.session_id, CodeBlock=compiled_code.lstrip() )["CalculationExecutionId"] - logger.debug(f"Submitted calculation execution id {calculation_execution_id}") + except Exception as e: + raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") + try: execution_status = self._poll_until_execution_completion(calculation_execution_id) - logger.debug(f"Received execution status {execution_status}") - if execution_status == "COMPLETED": - result_s3_uri = self.athena_client.get_calculation_execution( - CalculationExecutionId=calculation_execution_id - )["Result"]["ResultS3Uri"] - return result_s3_uri - else: - raise DbtRuntimeError(f"python model run ended in state {execution_status}") + except Exception as e: + logger.error(f"Unable to poll execution status: Got: {e}") finally: self._terminate_session() + logger.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + result_s3_uri = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"]["ResultS3Uri"] + return result_s3_uri + else: + raise DbtRuntimeError(f"python model run ended in state {execution_status}") + def _terminate_session(self) -> None: """ diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 6ac83ec0..8b175dae 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -1,16 +1,17 @@ -{%- macro athena__py_save_table_as(compiled_code, target_relation , **kwargs) -%} - {% set location = kwargs.get("location") %} - {% set format = kwargs.get("format", "parquet") %} - {% set mode = kwargs.get("mode", "overwrite") %} - {% set write_compression = kwargs.get("write_compression", "snappy") %} - {% set partitioned_by = kwargs.get("partitioned_by") %} - {% set bucketed_by = kwargs.get("bucketed_by") %} - {% set sorted_by = kwargs.get("sorted_by") %} - {% set merge_schema = kwargs.get("merge_schema", true) %} - {% set bucket_count = kwargs.get("bucket_count") %} - {% set field_delimiter = kwargs.get("field_delimiter") %} - {% set table_type = kwargs.get("table_type") %} - {% set extra_table_properties = kwargs.get("extra_table_properties") %} +{%- macro athena__py_save_table_as(compiled_code, target_relation, optional_args={}) -%} + {% set location = optional_args.get("location") %} + {% set format = optional_args.get("format", "parquet") %} + {% set mode = optional_args.get("mode", "overwrite") %} + {% set write_compression = optional_args.get("write_compression", "snappy") %} + {% set partitioned_by = optional_args.get("partitioned_by") %} + {% set bucketed_by = optional_args.get("bucketed_by") %} + {% set sorted_by = optional_args.get("sorted_by") %} + {% set merge_schema = optional_args.get("merge_schema", true) %} + {% set bucket_count = optional_args.get("bucket_count") %} + {% set field_delimiter = optional_args.get("field_delimiter") %} + +import pyspark + {{ compiled_code }} def materialize(spark_session, df, target_relation): @@ -23,20 +24,20 @@ def materialize(spark_session, df, target_relation): else: msg = f"{type(df)} is not a supported type for dbt Python materialization" raise Exception(msg) - df.write \ + writer = df.write \ .format("{{ format }}") \ .mode("{{ mode }}") \ - .partitionBy("{{ partitioned_by }}") \ - .bucketBy("{{ bucketed_by }}") \ - .sortBy(" {{ sorted_by }}") \ .option("path", "{{ location }}") \ .option("compression", "{{ write_compression }}") \ .option("mergeSchema", "{{ merge_schema }}") \ - .option("numBuckets", "{{ bucket_count }}") \ - .option("delimiter", "{{ field_delimiter }}") \ - .option("tableType", "{{ table_type }}") \ - .option("extra", "{{ extra_table_properties }}") \ - .saveAsTable( + .option("delimiter", "{{ field_delimiter }}") + if {{ partitioned_by }} is not None: + writer = writer.partitionBy({{ partitioned_by }}) + if {{ bucketed_by }} is not None: + writer = writer.bucketBy({{ bucket_count }},{{ bucketed_by }}) + if {{ sorted_by }} is not None: + writer = writer.sortBy({{ sorted_by }}) + writer.saveAsTable( name="{{ target_relation.schema}}.{{ target_relation.identifier }}", ) return "OK" @@ -45,12 +46,15 @@ def materialize(spark_session, df, target_relation): {{ athena__py_get_spark_dbt_object() }} -dbt = SparkdbtObj(spark) -df = model(dbt, dbt.spark_session) -materialize(dbt.spark_session, df, dbt.this) +dbt = SparkdbtObj() +df = model(dbt, spark) +materialize(spark, df, dbt.this) {%- endmacro -%} {%- macro athena__py_execute_query(query) -%} +import pyspark + + {{ athena__py_get_spark_dbt_object() }} def execute_query(spark_session): @@ -61,46 +65,29 @@ def execute_query(spark_session): except Exception: raise -dbt = SparkdbtObj(spark) -execute_query(dbt, dbt.spark_session) +dbt = SparkdbtObj() +execute_query(spark) {%- endmacro -%} {%- macro athena__py_get_spark_dbt_object() -%} -class SparkdbtObj(dbtObj, spark_session): - def __init__(self) -> None: - super().__init__(load_df_function=spark.table) - self.spark_session = spark_session - - @property - def source(self, *args): - """ - Override the source attribute dynamically - - spark.table('awsdatacatalog.analytics_dev.model') - Raises pyspark.sql.utils.AnalysisException: - spark_catalog requires a single-part namespace, - but got [awsdatacatalog, analytics_dev] +def get_spark_df(identifier): + """ + Override the arguments to ref and source dynamically - So the override removes the catalog component and only - provides the schema and identifer to spark.table() - """ - args = [arg[arg.index('.')+1:] for arg in args] - return lambda *args: source(*args, dbt_load_df_function=spark.table) - - @property - def ref(self, *args): - """ - Override the ref attribute dynamically + spark.table('awsdatacatalog.analytics_dev.model') + Raises pyspark.sql.utils.AnalysisException: + spark_catalog requires a single-part namespace, + but got [awsdatacatalog, analytics_dev] - spark.table('awsdatacatalog.analytics_dev.model') - Raises pyspark.sql.utils.AnalysisException: - spark_catalog requires a single-part namespace, - but got [awsdatacatalog, analytics_dev] + So the override removes the catalog component and only + provides the schema and identifer to spark.table() + """ + return spark.table(identifier.split(".", 1)[1]) - So the override removes the catalog component and only - provides the schema and identifer to spark.table() - """ - args = [arg[arg.index('.')+1:] for arg in args] - return lambda *args: ref(*args, dbt_load_df_function=spark.table) +class SparkdbtObj(dbtObj): + def __init__(self): + super().__init__(load_df_function=get_spark_df) + self.source = lambda *args: source(*args, dbt_load_df_function=get_spark_df) + self.ref = lambda *args: ref(*args, dbt_load_df_function=get_spark_df) {%- endmacro -%} \ No newline at end of file diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index e0793781..74644ae8 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -78,19 +78,20 @@ {{ compiled_code }} {%- elif language == 'python' -%} {{ athena__py_save_table_as( - compiled_code, - target_relation, - location=location, - format=format, - mode="overwrite", - partitioned_by=partitioned_by, - bucketed_by=bucketed_by, - write_compression=write_compression, - bucket_count=bucket_count, - field_delimiter=field_delimiter, - table_type=table_type, - extra_table_properties=extra_table_properties - ) }} + compiled_code, + relation, + optional_args={ + 'location': location, + 'format': format, + 'mode': 'overwrite', + 'partitioned_by': partitioned_by, + 'bucketed_by': bucketed_by, + 'write_compression': write_compression, + 'bucket_count': bucket_count, + 'field_delimiter': field_delimiter + } + ) + }} {%- else -%} {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} From 883519e346ea4d667f048397ba69b21de91bf4d6 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 24 Apr 2023 00:27:05 -0300 Subject: [PATCH 38/75] Renamed functions and fixed minor bugs --- dbt/adapters/athena/python_submissions.py | 58 ++++++++++++----------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 93126ac6..39c95615 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -10,7 +10,7 @@ from dbt.events import AdapterLogger from dbt.exceptions import DbtRuntimeError -DEFAULT_POLLING_INTERVAL = 5 +DEFAULTpollING_INTERVAL = 5 DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} SUBMISSION_LANGUAGE = "python" DEFAULT_TIMEOUT = 60 * 60 * 2 @@ -63,9 +63,9 @@ def schema(self) -> str: @property @lru_cache() def session_id(self) -> str: - return self._set_session_id() + return self.set_session_id() - def _set_session_id(self) -> str: + def set_session_id(self) -> str: """ Get the session ID. @@ -78,12 +78,14 @@ def _set_session_id(self) -> str: """ session_info = self._list_sessions() if session_info.get("SessionId") is None: - return self._start_session()["SessionId"] + return self.start_session()["SessionId"] return session_info["SessionId"] @property def spark_work_group(self) -> str: - return self.credentials.spark_work_group or "spark" + if self.credentials.spark_work_group is None: + raise ValueError("Need spark group for executing python functions. Add it to your dbt profile") + return self.credentials.spark_work_group def get_athena_client(self) -> Any: """ @@ -102,9 +104,9 @@ def get_athena_client(self) -> Any: @property def timeout(self) -> int: - return self._set_timeout() + return self.set_timeout() - def _set_timeout(self) -> int: + def set_timeout(self) -> int: """ Get the timeout value. @@ -133,10 +135,10 @@ def _set_timeout(self) -> int: @property def polling_interval(self): - return self._set_polling_interval() + return self.set_polling_interval() - def _set_polling_interval(self) -> int: - polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULT_POLLING_INTERVAL) + def set_polling_interval(self) -> int: + polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULTpollING_INTERVAL) if not isinstance(polling_interval, int) or polling_interval <= 0: raise ValueError("polling_interval must be a positive integer") logger.info(f"Setting polling_interval: {polling_interval}") @@ -144,9 +146,9 @@ def _set_polling_interval(self) -> int: @property def engine_config(self): - return self._set_engine_config() + return self.set_engine_config() - def _set_engine_config(self) -> dict: + def set_engine_config(self) -> dict: engine_config = self.parsed_model.get("config", {}).get("engine_config", DEFAULT_ENGINE_CONFIG) if not isinstance(engine_config, dict): raise TypeError("engine configuration has to be of type dict") @@ -174,7 +176,7 @@ def _list_sessions(self) -> dict: return {} return response.get("Sessions")[0] - def _start_session(self) -> dict: + def start_session(self) -> dict: """ Start an Athena session. @@ -191,27 +193,30 @@ def _start_session(self) -> dict: EngineConfiguration=self.engine_config, ) if response["State"] != "IDLE": - self._poll_until_session_creation(response["SessionId"]) + self.poll_until_session_creation(response["SessionId"]) return response - - def _get_current_session_status(self) -> str: + + def get_current_session_status(self) -> str: """ Get the current session status. Returns: str: The status of the session """ - return self.athena_client.get_session_status(SessionId=self.session_id)["Status"] - - def _poll_until_session_idle(self): + return self.athena_client.get_session_status(SessionId=self.session_id)["Status"]["State"] + + def poll_until_session_idle(self): polling_interval = self.polling_interval while True: - session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"]["State"] if session_status == "IDLE": return session_status if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: return DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") - + time.sleep(polling_interval) + polling_interval *= 2 + if polling_interval > self.timeout: + raise DbtRuntimeError(f"Session {self.session_id} did not become free within {self.timeout} seconds.") def submit(self, compiled_code: str) -> dict: """ @@ -233,8 +238,8 @@ def submit(self, compiled_code: str) -> dict: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ - if self._get_current_session_status() == "BUSY": - self._poll_until_session_idle() + if self.get_current_session_status() == "BUSY": + self.poll_until_session_idle() try: calculation_execution_id = self.athena_client.start_calculation_execution( SessionId=self.session_id, CodeBlock=compiled_code.lstrip() @@ -242,7 +247,7 @@ def submit(self, compiled_code: str) -> dict: except Exception as e: raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") try: - execution_status = self._poll_until_execution_completion(calculation_execution_id) + execution_status = self.poll_until_execution_completion(calculation_execution_id) except Exception as e: logger.error(f"Unable to poll execution status: Got: {e}") finally: @@ -256,7 +261,6 @@ def submit(self, compiled_code: str) -> dict: else: raise DbtRuntimeError(f"python model run ended in state {execution_status}") - def _terminate_session(self) -> None: """ Terminate the current Athena session. @@ -277,7 +281,7 @@ def _terminate_session(self) -> None: logger.debug(f"Terminating session: {self.session_id}") self.athena_client.terminate_session(SessionId=self.session_id) - def _poll_until_execution_completion(self, calculation_execution_id): + def poll_until_execution_completion(self, calculation_execution_id): """ Poll the status of a calculation execution until it is completed, failed, or cancelled. @@ -312,7 +316,7 @@ def _poll_until_execution_completion(self, calculation_execution_id): f"Execution {calculation_execution_id} did not complete within {self.timeout} seconds." ) - def _poll_until_session_creation(self, session_id): + def poll_until_session_creation(self, session_id): """ Polls the status of an Athena session creation until it is completed or reaches the timeout. From 890b601477cb30a437f5f74634d07d79b4a0e34f Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 24 Apr 2023 19:31:52 -0300 Subject: [PATCH 39/75] Fixed CI --- dbt/include/athena/macros/adapters/python_submissions.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 8b175dae..cb7fd16f 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -52,7 +52,7 @@ materialize(spark, df, dbt.this) {%- endmacro -%} {%- macro athena__py_execute_query(query) -%} -import pyspark +import pyspark {{ athena__py_get_spark_dbt_object() }} @@ -90,4 +90,4 @@ class SparkdbtObj(dbtObj): self.source = lambda *args: source(*args, dbt_load_df_function=get_spark_df) self.ref = lambda *args: ref(*args, dbt_load_df_function=get_spark_df) -{%- endmacro -%} \ No newline at end of file +{%- endmacro -%} From ee9bfd863040e68d79a3df56a31df3f43016d0d8 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 24 Apr 2023 19:57:22 -0300 Subject: [PATCH 40/75] Fix names in test --- dbt/adapters/athena/python_submissions.py | 12 ++++----- tests/unit/test_python_submissions.py | 30 +++++++++++------------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 39c95615..6d2b59d2 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -10,7 +10,7 @@ from dbt.events import AdapterLogger from dbt.exceptions import DbtRuntimeError -DEFAULTpollING_INTERVAL = 5 +DEFAULT_POLLING_INTERVAL = 5 DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} SUBMISSION_LANGUAGE = "python" DEFAULT_TIMEOUT = 60 * 60 * 2 @@ -76,7 +76,7 @@ def set_session_id(self) -> str: str: The session ID. """ - session_info = self._list_sessions() + session_info = self.list_sessions() if session_info.get("SessionId") is None: return self.start_session()["SessionId"] return session_info["SessionId"] @@ -138,7 +138,7 @@ def polling_interval(self): return self.set_polling_interval() def set_polling_interval(self) -> int: - polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULTpollING_INTERVAL) + polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULT_POLLING_INTERVAL) if not isinstance(polling_interval, int) or polling_interval <= 0: raise ValueError("polling_interval must be a positive integer") logger.info(f"Setting polling_interval: {polling_interval}") @@ -159,7 +159,7 @@ def set_engine_config(self) -> dict: return engine_config - def _list_sessions(self) -> dict: + def list_sessions(self) -> dict: """ List Athena sessions. @@ -251,7 +251,7 @@ def submit(self, compiled_code: str) -> dict: except Exception as e: logger.error(f"Unable to poll execution status: Got: {e}") finally: - self._terminate_session() + self.terminate_session() logger.debug(f"Received execution status {execution_status}") if execution_status == "COMPLETED": result_s3_uri = self.athena_client.get_calculation_execution( @@ -261,7 +261,7 @@ def submit(self, compiled_code: str) -> dict: else: raise DbtRuntimeError(f"python model run ended in state {execution_status}") - def _terminate_session(self) -> None: + def terminate_session(self) -> None: """ Terminate the current Athena session. diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 406ad05b..0c1e174d 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -45,14 +45,14 @@ def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): ), ], ) - def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: + def teststart_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: """ - Test the _start_session method of the AthenaJobHelper class. + Test the start_session method of the AthenaJobHelper class. Args: session_status_response (dict): A dictionary containing the response from the Athena session creation status. - expected_response (Union[dict, DbtRuntimeError]): The expected response from the _start_session method. + expected_response (Union[dict, DbtRuntimeError]): The expected response from the start_session method. athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. @@ -61,13 +61,13 @@ def test_start_session(self, session_status_response, expected_response, athena_ """ with patch.multiple( athena_job_helper, - _poll_until_session_creation=Mock(return_value=session_status_response), + poll_until_session_creation=Mock(return_value=session_status_response), ), patch.multiple( athena_client, get_session_status=Mock(return_value=session_status_response), start_session=Mock(return_value=session_status_response.get("Status")), ): - response = athena_job_helper._start_session() + response = athena_job_helper.start_session() assert response == expected_response @pytest.mark.parametrize( @@ -134,7 +134,7 @@ def test_list_sessions(self, session_status_response, expected_response, athena_ AssertionError: If the output of the _list_sessions method does not match the expected output. """ with patch.object(athena_client, "list_sessions", return_value=session_status_response): - response = athena_job_helper._list_sessions() + response = athena_job_helper.list_sessions() assert response == expected_response @pytest.mark.parametrize( @@ -162,7 +162,7 @@ def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, m None """ monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - response = athena_job_helper._set_timeout() + response = athena_job_helper.set_timeout() assert response == expected_timeout @pytest.mark.parametrize( @@ -181,7 +181,7 @@ def test_set_polling_interval( self, parsed_models, expected_polling_interval, athena_job_helper, monkeypatch ) -> None: """ - Test method to verify that _set_polling_interval() method of AthenaPythonJobHelper + Test method to verify that set_polling_interval() method of AthenaPythonJobHelper sets the correct polling interval value based on the parsed model configuration. Args: @@ -196,7 +196,7 @@ def test_set_polling_interval( None """ monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - response = athena_job_helper._set_polling_interval() + response = athena_job_helper.set_polling_interval() assert response == expected_polling_interval @pytest.mark.parametrize( @@ -234,7 +234,7 @@ def test_set_polling_interval( ) def test_set_engine_config(self, parsed_models, expected_engine_config, athena_job_helper, monkeypatch) -> None: """ - Test method to verify the `_set_engine_config()` method of the AthenaPythonJobHelper class. + Test method to verify the `set_engine_config()` method of the AthenaPythonJobHelper class. Args: parsed_models: A dictionary containing the parsed model configuration data. @@ -251,8 +251,8 @@ def test_set_engine_config(self, parsed_models, expected_engine_config, athena_j monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) if parsed_models.get("alias") == "test_wrong_model": with pytest.raises(KeyError): - athena_job_helper._set_engine_config() - response = athena_job_helper._set_engine_config() + athena_job_helper.set_engine_config() + response = athena_job_helper.set_engine_config() assert response == expected_engine_config @pytest.mark.parametrize( @@ -315,10 +315,10 @@ def test_terminate_session( terminate_session=Mock(return_value=expected_response), ), patch.multiple( athena_job_helper, - _set_session_id=Mock(return_value=test_session_id), - _set_timeout=Mock(return_value=10), + set_session_id=Mock(return_value=test_session_id), + set_timeout=Mock(return_value=10), ): - terminate_session_response = athena_job_helper._terminate_session() + terminate_session_response = athena_job_helper.terminate_session() assert terminate_session_response == expected_response def test_poll_session_creation(self): From 24cb7ff0a34dd8336980c20fe3b17b163c0aca4f Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Wed, 26 Apr 2023 15:30:34 -0300 Subject: [PATCH 41/75] Handle botocore client error --- dbt/adapters/athena/python_submissions.py | 41 ++++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 6d2b59d2..2fb43c05 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -4,6 +4,7 @@ from typing import Any, Dict import boto3 +import botocore from dbt.adapters.athena.connections import AthenaCredentials from dbt.adapters.base import PythonJobHelper @@ -63,7 +64,9 @@ def schema(self) -> str: @property @lru_cache() def session_id(self) -> str: - return self.set_session_id() + session_id = self.set_session_id() + logger.info(f"Setting session id: {session_id}") + return session_id def set_session_id(self) -> str: """ @@ -84,7 +87,7 @@ def set_session_id(self) -> str: @property def spark_work_group(self) -> str: if self.credentials.spark_work_group is None: - raise ValueError("Need spark group for executing python functions. Add it to your dbt profile") + raise ValueError("Need a spark group for executing python functions. Add it to your dbt profile.") return self.credentials.spark_work_group def get_athena_client(self) -> Any: @@ -103,6 +106,7 @@ def get_athena_client(self) -> Any: ).client("athena") @property + @lru_cache() def timeout(self) -> int: return self.set_timeout() @@ -134,6 +138,7 @@ def set_timeout(self) -> int: return timeout @property + @lru_cache() def polling_interval(self): return self.set_polling_interval() @@ -145,6 +150,7 @@ def set_polling_interval(self) -> int: return polling_interval @property + @lru_cache() def engine_config(self): return self.set_engine_config() @@ -203,12 +209,12 @@ def get_current_session_status(self) -> str: Returns: str: The status of the session """ - return self.athena_client.get_session_status(SessionId=self.session_id)["Status"]["State"] + return self.athena_client.get_session_status(SessionId=self.session_id)["Status"] def poll_until_session_idle(self): polling_interval = self.polling_interval while True: - session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"]["State"] + session_status = self.get_current_session_status()["State"] if session_status == "IDLE": return session_status if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: @@ -238,14 +244,23 @@ def submit(self, compiled_code: str) -> dict: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ - if self.get_current_session_status() == "BUSY": - self.poll_until_session_idle() - try: - calculation_execution_id = self.athena_client.start_calculation_execution( - SessionId=self.session_id, CodeBlock=compiled_code.lstrip() - )["CalculationExecutionId"] - except Exception as e: - raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") + while True: + try: + calculation_execution_id = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.lstrip() + )["CalculationExecutionId"] + break + except botocore.exceptions.ClientError as ce: + logger.exception(f"Encountered client error: {ce}") + if ( + ce.response["Error"]["Code"] == "InvalidRequestException" + and "Session is in the BUSY state; needs to be IDLE to accept Calculations." + in ce.response["Error"]["Message"] + ): + logger.exception("Going to poll until session is IDLE") + self.poll_until_session_idle() + except Exception as e: + raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") try: execution_status = self.poll_until_execution_completion(calculation_execution_id) except Exception as e: @@ -274,7 +289,7 @@ def terminate_session(self) -> None: dict: The response from the Athena client after terminating the session. """ - session_status = self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + session_status = self.get_current_session_status() if session_status["State"] in ["IDLE", "BUSY"] and ( session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) ): From 08ec29f22fa6ddee865d7ad44a9ee6eb68bec790 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 30 Apr 2023 19:33:58 -0300 Subject: [PATCH 42/75] Fix conflict --- .../macros/materializations/models/table/table.sql | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index 5857db21..d4e8d2cf 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -13,10 +13,11 @@ {{ run_hooks(pre_hooks) }} - -- cleanup - {%- if old_relation is not none -%} - {{ drop_relation(old_relation) }} - {%- endif -%} + {%- if old_relation is none or table_type != 'iceberg' -%} + -- cleanup + {%- if old_relation is not none and language != 'python' -%} + {{ drop_relation(old_relation) }} + {%- endif -%} {%- call statement('main', language=language) -%} {{ create_table_as(False, target_relation, compiled_code, language) }} From 2720dfcdc17daae6819fcb824b056fa827d726bf Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Wed, 3 May 2023 19:43:15 -0300 Subject: [PATCH 43/75] Added threading support --- dbt/adapters/athena/python_submissions.py | 390 ++++++++++-------- .../macros/adapters/python_submissions.sql | 4 - .../materializations/models/table/table.sql | 4 +- tests/unit/test_python_submissions.py | 2 +- 4 files changed, 223 insertions(+), 177 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 2fb43c05..a3edbf0a 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -1,7 +1,9 @@ +import threading import time +import uuid from datetime import datetime, timedelta, timezone from functools import lru_cache -from typing import Any, Dict +from typing import Dict, List import boto3 import botocore @@ -15,82 +17,74 @@ DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} SUBMISSION_LANGUAGE = "python" DEFAULT_TIMEOUT = 60 * 60 * 2 +DEFAULT_SESSION_COUNT = 16 logger = AdapterLogger("Athena") +session_locks = {} -class AthenaPythonJobHelper(PythonJobHelper): - """ - A helper class for executing Python jobs on AWS Athena. +class AthenaSparkSessionConfig: + def __init__(self, config: dict): + self.config = config - This class extends the base `PythonJobHelper` class and provides additional functionality - specific to executing jobs on Athena. It takes a parsed model and credentials as inputs - during initialization, and provides methods for executing Athena jobs, setting timeout, - polling interval, region name, AWS profile name, and Spark work group. + def set_timeout(self) -> int: + """ + Get the timeout value. - Args: - parsed_model (Dict): A dictionary representing the parsed model of the Athena job. - It should contain keys such as 'alias' for job identifier and 'schema' for - job schema. - credentials (AthenaCredentials): An instance of the `AthenaCredentials` class - containing AWS credentials for accessing Athena. + This function retrieves the timeout value from the parsed model's configuration. If the timeout value + is not defined, it falls back to the default timeout value. If the retrieved timeout value is less than or + equal to 0, a ValueError is raised as timeout must be a positive integer. - Attributes: - identifier (str): A string representing the alias or identifier of the Athena job. - schema (str): A string representing the schema of the Athena job. - parsed_model (Dict): A dictionary representing the parsed model of the Athena job. - timeout (int): An integer representing the timeout value in seconds for the Athena job. - polling_interval (int): An integer representing the polling interval in seconds for - checking the status of the Athena job. - region_name (str): A string representing the AWS region name for executing the Athena job. - profile_name (str): A string representing the AWS profile name for accessing Athena. - spark_work_group (str): A string representing the Spark work group for executing the Athena job. + Returns: + int: The timeout value in seconds. - """ + Raises: + ValueError: If the timeout value is not a positive integer. - def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: - self.parsed_model = parsed_model - self.credentials = credentials - self.athena_client = self.get_athena_client() + """ + timeout = self.config.get("timeout", DEFAULT_TIMEOUT) + if not isinstance(timeout, int): + raise TypeError("Timeout must be an integer") + if timeout <= 0: + raise ValueError("Timeout must be a positive integer") + logger.debug(f"Setting timeout: {timeout}") + return timeout - @property - def identifier(self) -> str: - return self.parsed_model["alias"] + def set_polling_interval(self) -> int: + polling_interval = self.config.get("polling_interval", DEFAULT_POLLING_INTERVAL) + if not isinstance(polling_interval, int) or polling_interval <= 0: + raise ValueError("polling_interval must be a positive integer") + logger.debug(f"Setting polling_interval: {polling_interval}") + return polling_interval - @property - def schema(self) -> str: - return self.parsed_model["schema"] + def set_engine_config(self) -> dict: + engine_config = self.config.get("engine_config", DEFAULT_ENGINE_CONFIG) + if not isinstance(engine_config, dict): + raise TypeError("engine configuration has to be of type dict") - @property - @lru_cache() - def session_id(self) -> str: - session_id = self.set_session_id() - logger.info(f"Setting session id: {session_id}") - return session_id + expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} + if set(engine_config.keys()) != expected_keys: + raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") + return engine_config - def set_session_id(self) -> str: - """ - Get the session ID. - This function retrieves the session ID from the stored session information. If session information - is not available, a new session is started and its session ID is returned. +class AthenaSparkSessionManager: + """_summary_ - Returns: - str: The session ID. + Args: + AthenaConnectionManager (AthenaConnectionManager): _description_ + """ - """ - session_info = self.list_sessions() - if session_info.get("SessionId") is None: - return self.start_session()["SessionId"] - return session_info["SessionId"] + def __init__(self, credentials: str, **kwargs): + self.credentials = credentials + self.timeout = kwargs.get("timeout") + self.polling_interval = kwargs.get("polling_interval") + self.engine_config = kwargs.get("engine_config") + self.lock = threading.Lock() @property - def spark_work_group(self) -> str: - if self.credentials.spark_work_group is None: - raise ValueError("Need a spark group for executing python functions. Add it to your dbt profile.") - return self.credentials.spark_work_group - - def get_athena_client(self) -> Any: + @lru_cache + def athena_client(self): """ Get the AWS Athena client. @@ -105,69 +99,42 @@ def get_athena_client(self) -> Any: region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name ).client("athena") - @property - @lru_cache() - def timeout(self) -> int: - return self.set_timeout() + def get_sessions(self) -> List[uuid.UUID]: + sessions = self.list_sessions() + existing_sessions = set(session_locks.keys()) + new_sessions = [session["SessionId"] for session in sessions if session["SessionId"] not in existing_sessions] + logger.debug(f"Setting sessions: {new_sessions}") + return [uuid.UUID(session) for session in new_sessions] - def set_timeout(self) -> int: + def update_session_locks(self) -> None: + for session_uuid in self.get_sessions(): + session_locks.setdefault(session_uuid, threading.Lock()) + logger.debug(f"Updated session locks: {session_locks}") + + def get_session_id(self) -> str: """ - Get the timeout value. + Get the session ID. - This function retrieves the timeout value from the parsed model's configuration. If the timeout value - is not defined, it falls back to the default timeout value. If the retrieved timeout value is less than or - equal to 0, a ValueError is raised as timeout must be a positive integer. + This function retrieves the session ID from the stored session information. If session information + is not available, a new session is started and its session ID is returned. Returns: - int: The timeout value in seconds. - - Raises: - ValueError: If the timeout value is not a positive integer. + str: The session ID. """ - if self.parsed_model.get("config") is not None: - timeout = self.parsed_model["config"].get("timeout", DEFAULT_TIMEOUT) - if not isinstance(timeout, int): - raise TypeError("Timeout must be an integer") - if timeout <= 0: - raise ValueError("Timeout must be a positive integer") - logger.info(f"Setting timeout: {timeout}") - else: - logger.info(f"Using default timeout: {DEFAULT_TIMEOUT}") - timeout = DEFAULT_TIMEOUT - return timeout - - @property - @lru_cache() - def polling_interval(self): - return self.set_polling_interval() - - def set_polling_interval(self) -> int: - polling_interval = self.parsed_model.get("config", {}).get("polling_interval", DEFAULT_POLLING_INTERVAL) - if not isinstance(polling_interval, int) or polling_interval <= 0: - raise ValueError("polling_interval must be a positive integer") - logger.info(f"Setting polling_interval: {polling_interval}") - return polling_interval - - @property - @lru_cache() - def engine_config(self): - return self.set_engine_config() - - def set_engine_config(self) -> dict: - engine_config = self.parsed_model.get("config", {}).get("engine_config", DEFAULT_ENGINE_CONFIG) - if not isinstance(engine_config, dict): - raise TypeError("engine configuration has to be of type dict") - - expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} - if set(engine_config.keys()) != expected_keys: - raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") - - return engine_config - - def list_sessions(self) -> dict: + self.update_session_locks() + with self.lock: + for session_uuid, lock in session_locks.items(): + if not lock.locked(): + logger.debug(f"Locking existing session: {session_uuid}") + lock.acquire() + return session_uuid + logger.debug("All sessions are currently locked. Starting new session.") + return self.start_session() + + def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = "IDLE") -> dict: """ - List Athena sessions. + List idle athena spark sessions. This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions @@ -177,10 +144,12 @@ def list_sessions(self) -> dict: dict: The session information dictionary if an idle session is found, None otherwise. """ - response = self.athena_client.list_sessions(WorkGroup=self.spark_work_group, MaxResults=1, StateFilter="IDLE") + response = self.athena_client.list_sessions( + WorkGroup=self.credentials.spark_work_group, MaxResults=max_results, StateFilter=state + ) if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: return {} - return response.get("Sessions")[0] + return response.get("Sessions") def start_session(self) -> dict: """ @@ -194,13 +163,141 @@ def start_session(self) -> dict: dict: The session information dictionary. """ - response = self.athena_client.start_session( - WorkGroup=self.spark_work_group, - EngineConfiguration=self.engine_config, + with self.lock: + if len(session_locks) >= DEFAULT_SESSION_COUNT: + # Raise this exception but also poll until a session is free and assign that + raise Exception( + f"""Maximum session count: {DEFAULT_SESSION_COUNT} reached. + Cannot start new spark session.""" + ) + response = self.athena_client.start_session( + WorkGroup=self.credentials.spark_work_group, + EngineConfiguration=self.engine_config, + ) + if response["State"] != "IDLE": + self.poll_until_session_creation(response["SessionId"]) + session_uuid = uuid.UUID(response["SessionId"]) + logger.debug(f"Locking new session: {session_uuid}") + lock = threading.Lock() + session_locks[session_uuid] = lock + session_locks[session_uuid].acquire() + return session_uuid + + def poll_until_session_creation(self, session_id): + """ + Polls the status of an Athena session creation until it is completed or reaches the timeout. + + Args: + session_id (str): The ID of the session being created. + + Returns: + str: The final status of the session, which will be "IDLE" if the session creation is successful. + + Raises: + DbtRuntimeError: If the session creation fails, is terminated, or degrades during polling. + DbtRuntimeError: If the session does not become IDLE within the specified timeout. + + """ + polling_interval = self.polling_interval + while True: + creation_status = self.get_session_status(session_id)["State"] + if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: + raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") + elif creation_status == "IDLE": + return creation_status + time.sleep(polling_interval) + polling_interval *= 2 + if polling_interval > self.timeout: + raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") + + def release_session_lock(self, session_id) -> None: + """ + Terminate the current Athena session. + + This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the + configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the + duration since the session start time exceeds the timeout period, the session is terminated. The session ID is + used to terminate the session via the Athena client. + + Returns: + dict: The response from the Athena client after terminating the session. + + """ + session_status = self.get_session_status(session_id) + if session_status["State"] in ["IDLE", "BUSY"] and ( + session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) + ): + logger.debug(f"Terminating session: {session_id}") + self.athena_client.terminate_session(SessionId=session_id) + with self.lock: + logger.debug(f"Releasing lock for session: {session_id}") + session_locks[uuid.UUID(session_id)].release() + + def get_session_status(self, session_id) -> str: + """ + Get the session status. + + Returns: + str: The status of the session + """ + return self.athena_client.get_session_status(SessionId=session_id)["Status"] + + +class AthenaPythonJobHelper(PythonJobHelper): + """ + A helper class for executing Python jobs on AWS Athena. + + This class extends the base `PythonJobHelper` class and provides additional functionality + specific to executing jobs on Athena. It takes a parsed model and credentials as inputs + during initialization, and provides methods for executing Athena jobs, setting timeout, + polling interval, region name, AWS profile name, and Spark work group. + + Args: + parsed_model (Dict): A dictionary representing the parsed model of the Athena job. + It should contain keys such as 'alias' for job identifier and 'schema' for + job schema. + credentials (AthenaCredentials): An instance of the `AthenaCredentials` class + containing AWS credentials for accessing Athena. + + Attributes: + identifier (str): A string representing the alias or identifier of the Athena job. + schema (str): A string representing the schema of the Athena job. + parsed_model (Dict): A dictionary representing the parsed model of the Athena job. + timeout (int): An integer representing the timeout value in seconds for the Athena job. + polling_interval (int): An integer representing the polling interval in seconds for + checking the status of the Athena job. + region_name (str): A string representing the AWS region name for executing the Athena job. + profile_name (str): A string representing the AWS profile name for accessing Athena. + spark_work_group (str): A string representing the Spark work group for executing the Athena job. + + """ + + def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: + self.config = AthenaSparkSessionConfig(parsed_model.get("config", {})) + self.spark_connection = AthenaSparkSessionManager( + credentials, timeout=self.timeout, polling_interval=self.polling_interval, engine_config=self.engine_config ) - if response["State"] != "IDLE": - self.poll_until_session_creation(response["SessionId"]) - return response + self.athena_client = self.spark_connection.athena_client + + @property + @lru_cache() + def timeout(self): + return self.config.set_timeout() + + @property + @lru_cache() + def session_id(self) -> str: + return str(self.spark_connection.get_session_id()) + + @property + @lru_cache() + def polling_interval(self): + return self.config.set_polling_interval() + + @property + @lru_cache() + def engine_config(self): + return self.config.set_engine_config() def get_current_session_status(self) -> str: """ @@ -266,7 +363,7 @@ def submit(self, compiled_code: str) -> dict: except Exception as e: logger.error(f"Unable to poll execution status: Got: {e}") finally: - self.terminate_session() + self.spark_connection.release_session_lock(self.session_id) logger.debug(f"Received execution status {execution_status}") if execution_status == "COMPLETED": result_s3_uri = self.athena_client.get_calculation_execution( @@ -276,26 +373,6 @@ def submit(self, compiled_code: str) -> dict: else: raise DbtRuntimeError(f"python model run ended in state {execution_status}") - def terminate_session(self) -> None: - """ - Terminate the current Athena session. - - This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the - configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the - duration since the session start time exceeds the timeout period, the session is terminated. The session ID is - used to terminate the session via the Athena client. - - Returns: - dict: The response from the Athena client after terminating the session. - - """ - session_status = self.get_current_session_status() - if session_status["State"] in ["IDLE", "BUSY"] and ( - session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) - ): - logger.debug(f"Terminating session: {self.session_id}") - self.athena_client.terminate_session(SessionId=self.session_id) - def poll_until_execution_completion(self, calculation_execution_id): """ Poll the status of a calculation execution until it is completed, failed, or cancelled. @@ -330,30 +407,3 @@ def poll_until_execution_completion(self, calculation_execution_id): raise DbtRuntimeError( f"Execution {calculation_execution_id} did not complete within {self.timeout} seconds." ) - - def poll_until_session_creation(self, session_id): - """ - Polls the status of an Athena session creation until it is completed or reaches the timeout. - - Args: - session_id (str): The ID of the session being created. - - Returns: - str: The final status of the session, which will be "IDLE" if the session creation is successful. - - Raises: - DbtRuntimeError: If the session creation fails, is terminated, or degrades during polling. - DbtRuntimeError: If the session does not become IDLE within the specified timeout. - - """ - polling_interval = self.polling_interval - while True: - creation_status = self.athena_client.get_session_status(SessionId=session_id)["Status"]["State"] - if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: - raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") - elif creation_status == "IDLE": - return creation_status - time.sleep(polling_interval) - polling_interval *= 2 - if polling_interval > self.timeout: - raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index cb7fd16f..f0b31215 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -52,13 +52,9 @@ materialize(spark, df, dbt.this) {%- endmacro -%} {%- macro athena__py_execute_query(query) -%} -import pyspark - - {{ athena__py_get_spark_dbt_object() }} def execute_query(spark_session): - import pandas try: spark_session.sql("""{{ query }}""") return "OK" diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index d4e8d2cf..647f5c79 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -45,8 +45,8 @@ {%- do drop_relation(old_relation_bkp) -%} {%- endif -%} - {%- call statement('main') -%} - {{ create_table_as(False, tmp_relation, sql) }} + {%- call statement('main', language=language) -%} + {{ create_table_as(False, tmp_relation, compiled_code, language) }} {%- endcall -%} {{ rename_relation(old_relation, old_relation_bkp) }} diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 0c1e174d..97919ab0 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -45,7 +45,7 @@ def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): ), ], ) - def teststart_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: + def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: """ Test the start_session method of the AthenaJobHelper class. From 71cd4ee1463427864d9c4e2dd9d8564d0ed0bb43 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 5 May 2023 18:33:15 -0300 Subject: [PATCH 44/75] Added tests for python job helper --- dbt/adapters/athena/python_submissions.py | 78 +-- tests/unit/test_python_submissions.py | 591 ++++++++++++++-------- 2 files changed, 426 insertions(+), 243 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index a3edbf0a..93e67d8a 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -24,6 +24,10 @@ class AthenaSparkSessionConfig: + """ + A helper class to manage Athena Spark Session Configuration. + """ + def __init__(self, config: dict): self.config = config @@ -69,10 +73,8 @@ def set_engine_config(self) -> dict: class AthenaSparkSessionManager: - """_summary_ - - Args: - AthenaConnectionManager (AthenaConnectionManager): _description_ + """ + A helper class to manage Athena Spark Sessions. """ def __init__(self, credentials: str, **kwargs): @@ -81,10 +83,9 @@ def __init__(self, credentials: str, **kwargs): self.polling_interval = kwargs.get("polling_interval") self.engine_config = kwargs.get("engine_config") self.lock = threading.Lock() + self.athena_client = self.get_athena_client() - @property - @lru_cache - def athena_client(self): + def get_athena_client(self): """ Get the AWS Athena client. @@ -130,11 +131,16 @@ def get_session_id(self) -> str: lock.acquire() return session_uuid logger.debug("All sessions are currently locked. Starting new session.") - return self.start_session() + session_uuid = uuid.UUID(self.start_session()) + with self.lock: + logger.debug(f"Locking new session: {session_uuid}") + session_locks[session_uuid] = threading.Lock() + session_locks[session_uuid].acquire() + return session_uuid def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = "IDLE") -> dict: """ - List idle athena spark sessions. + List athena spark sessions. This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions @@ -163,25 +169,19 @@ def start_session(self) -> dict: dict: The session information dictionary. """ - with self.lock: - if len(session_locks) >= DEFAULT_SESSION_COUNT: - # Raise this exception but also poll until a session is free and assign that - raise Exception( - f"""Maximum session count: {DEFAULT_SESSION_COUNT} reached. - Cannot start new spark session.""" - ) - response = self.athena_client.start_session( - WorkGroup=self.credentials.spark_work_group, - EngineConfiguration=self.engine_config, + if len(session_locks) >= DEFAULT_SESSION_COUNT: + # Raise this exception but also poll until a session is free and assign that + raise Exception( + f"""Maximum session count: {DEFAULT_SESSION_COUNT} reached. + Cannot start new spark session.""" ) - if response["State"] != "IDLE": - self.poll_until_session_creation(response["SessionId"]) - session_uuid = uuid.UUID(response["SessionId"]) - logger.debug(f"Locking new session: {session_uuid}") - lock = threading.Lock() - session_locks[session_uuid] = lock - session_locks[session_uuid].acquire() - return session_uuid + response = self.athena_client.start_session( + WorkGroup=self.credentials.spark_work_group, + EngineConfiguration=self.engine_config, + ) + if response["State"] != "IDLE": + self.poll_until_session_creation(response["SessionId"]) + return response["SessionId"] def poll_until_session_creation(self, session_id): """ @@ -233,7 +233,7 @@ def release_session_lock(self, session_id) -> None: logger.debug(f"Releasing lock for session: {session_id}") session_locks[uuid.UUID(session_id)].release() - def get_session_status(self, session_id) -> str: + def get_session_status(self, session_id) -> dict: """ Get the session status. @@ -277,7 +277,7 @@ def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.spark_connection = AthenaSparkSessionManager( credentials, timeout=self.timeout, polling_interval=self.polling_interval, engine_config=self.engine_config ) - self.athena_client = self.spark_connection.athena_client + self.athena_client = self.spark_connection.get_athena_client() @property @lru_cache() @@ -306,7 +306,7 @@ def get_current_session_status(self) -> str: Returns: str: The status of the session """ - return self.athena_client.get_session_status(SessionId=self.session_id)["Status"] + return self.spark_connection.get_session_status(self.session_id) def poll_until_session_idle(self): polling_interval = self.polling_interval @@ -315,7 +315,7 @@ def poll_until_session_idle(self): if session_status == "IDLE": return session_status if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: - return DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") + raise DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") time.sleep(polling_interval) polling_interval *= 2 if polling_interval > self.timeout: @@ -360,18 +360,18 @@ def submit(self, compiled_code: str) -> dict: raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") try: execution_status = self.poll_until_execution_completion(calculation_execution_id) + logger.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + result_s3_uri = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"]["ResultS3Uri"] + return result_s3_uri + else: + raise DbtRuntimeError(f"python model run ended in state {execution_status}") except Exception as e: logger.error(f"Unable to poll execution status: Got: {e}") finally: self.spark_connection.release_session_lock(self.session_id) - logger.debug(f"Received execution status {execution_status}") - if execution_status == "COMPLETED": - result_s3_uri = self.athena_client.get_calculation_execution( - CalculationExecutionId=calculation_execution_id - )["Result"]["ResultS3Uri"] - return result_s3_uri - else: - raise DbtRuntimeError(f"python model run ended in state {execution_status}") def poll_until_execution_completion(self, calculation_execution_id): """ diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 97919ab0..67fab4c9 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,123 +1,141 @@ +import time from datetime import datetime from unittest.mock import Mock, patch import pytest import pytz -from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper +from dbt.adapters.athena.python_submissions import ( + AthenaPythonJobHelper, + AthenaSparkSessionConfig, + AthenaSparkSessionManager, +) from dbt.exceptions import DbtRuntimeError from .constants import DATABASE_NAME -@pytest.mark.usefixtures("athena_credentials", "athena_client") -class TestPythonSubmission: +class TestAthenaSparkSessionConfig: """ - A class to test the AthenaPythonJobHelper + A class to test AthenaSparkSessionConfig """ - @pytest.fixture() - def athena_job_helper(self, athena_credentials, athena_client, monkeypatch): - parsed_model = {"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}} - mock_job_helper = AthenaPythonJobHelper(parsed_model, athena_credentials) - monkeypatch.setattr(mock_job_helper, "athena_client", athena_client) - return mock_job_helper + @pytest.fixture + def spark_config(self, request): + return { + "timeout": request.param.get("timeout", 7200), + "polling_interval": request.param.get("polling_interval", 5), + "engine_config": request.param.get( + "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + ), + } + + @pytest.fixture + def spark_config_helper(self, spark_config): + return AthenaSparkSessionConfig(spark_config) @pytest.mark.parametrize( - "session_status_response, expected_response", + "spark_config", [ - pytest.param( - {"Status": {"SessionId": "test_session_id", "State": "CREATING"}}, - DbtRuntimeError( - """Session - did not create within 10 seconds.""" - ), - marks=pytest.mark.xfail, - ), - ( - {"Status": {"SessionId": "test_session_id", "State": "IDLE"}}, - {"SessionId": "test_session_id", "State": "IDLE"}, - ), - pytest.param( - {"Status": {"SessionId": "test_session_id", "State": "TERMINATED"}}, - DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."), - marks=pytest.mark.xfail, - ), + {"timeout": 5}, + {"timeout": 10}, + {"timeout": 20}, + {}, + pytest.param({"timeout": -1}, marks=pytest.mark.xfail), + pytest.param({"timeout": None}, marks=pytest.mark.xfail), ], + indirect=True, ) - def test_start_session(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: - """ - Test the start_session method of the AthenaJobHelper class. + def test_set_timeout(self, spark_config_helper): + timeout = spark_config_helper.set_timeout() + assert timeout == spark_config_helper.config.get("timeout", 7200) - Args: - session_status_response (dict): A dictionary containing the response from the Athena session - creation status. - expected_response (Union[dict, DbtRuntimeError]): The expected response from the start_session method. - athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. - athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. + @pytest.mark.parametrize( + "spark_config", + [ + {"polling_interval": 5}, + {"polling_interval": 10}, + {"polling_interval": 20}, + {}, + pytest.param({"polling_interval": -1}, marks=pytest.mark.xfail), + ], + indirect=True, + ) + def test_set_polling_interval(self, spark_config_helper): + polling_interval = spark_config_helper.set_polling_interval() + assert polling_interval == spark_config_helper.config.get("polling_interval", 5) - Returns: - None - """ - with patch.multiple( - athena_job_helper, - poll_until_session_creation=Mock(return_value=session_status_response), - ), patch.multiple( - athena_client, - get_session_status=Mock(return_value=session_status_response), - start_session=Mock(return_value=session_status_response.get("Status")), - ): - response = athena_job_helper.start_session() - assert response == expected_response + @pytest.mark.parametrize( + "spark_config", + [ + {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}}, + {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 2}}, + {}, + pytest.param({"engine_config": {"CoordinatorDpuSize": 1}}, marks=pytest.mark.xfail), + pytest.param({"engine_config": [1, 1, 1]}, marks=pytest.mark.xfail), + ], + indirect=True, + ) + def test_set_engine_config(self, spark_config_helper): + engine_config = spark_config_helper.set_engine_config() + assert engine_config == spark_config_helper.config.get( + "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + ) + + +@pytest.mark.usefixtures("athena_credentials", "athena_client") +class TestAthenaSparkSessionManager: + """ + A class to test the AthenaSparkSessionManager + """ + + @pytest.fixture + def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): + mock_session_manager = AthenaSparkSessionManager( + athena_credentials, + timeout=10, + polling_interval=5, + engine_config={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + ) + monkeypatch.setattr(mock_session_manager, "athena_client", athena_client) + return mock_session_manager @pytest.mark.parametrize( "session_status_response, expected_response", [ pytest.param( { - "NextToken": "test_token", "Sessions": [ { - "Description": "string", - "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, - "NotebookVersion": "string", "SessionId": "string", "Status": { - "EndDateTime": "number", - "IdleSinceDateTime": "number", - "LastModifiedDateTime": "number", "StartDateTime": "number", "State": "IDLE", - "StateChangeReason": "string", }, } ], }, - { - "Description": "string", - "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, - "NotebookVersion": "string", - "SessionId": "string", - "Status": { - "EndDateTime": "number", - "IdleSinceDateTime": "number", - "LastModifiedDateTime": "number", - "StartDateTime": "number", - "State": "IDLE", - "StateChangeReason": "string", - }, - }, + [ + { + "SessionId": "string", + "Status": { + "StartDateTime": "number", + "State": "IDLE", + }, + } + ], ), ( { - "NextToken": "string", "Sessions": [], }, {}, ), ], ) - def test_list_sessions(self, session_status_response, expected_response, athena_job_helper, athena_client) -> None: + def test_list_sessions( + self, session_status_response, expected_response, spark_session_manager, athena_client + ) -> None: """ Test the _list_sessions method of the AthenaJobHelper class. @@ -134,198 +152,363 @@ def test_list_sessions(self, session_status_response, expected_response, athena_ AssertionError: If the output of the _list_sessions method does not match the expected output. """ with patch.object(athena_client, "list_sessions", return_value=session_status_response): - response = athena_job_helper.list_sessions() + response = spark_session_manager.list_sessions() assert response == expected_response @pytest.mark.parametrize( - "parsed_models, expected_timeout", + "session_status_response, expected_response", [ - ({"alias": "test_model", "schema": DATABASE_NAME, "config": {"timeout": 10}}, 10), pytest.param( - {"alias": "test_model", "schema": "test_database", "config": {"timeout": 0}}, 0, marks=pytest.mark.xfail + {"Status": {"SessionId": "test_session_id", "State": "CREATING"}}, + DbtRuntimeError( + """Session + did not create within 10 seconds.""" + ), + marks=pytest.mark.xfail, + ), + ( + {"Status": {"SessionId": "test_session_id", "State": "IDLE"}}, + "test_session_id", + ), + pytest.param( + {"Status": {"SessionId": "test_session_id", "State": "TERMINATED"}}, + DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."), + marks=pytest.mark.xfail, ), - ({"alias": "test_model", "schema": DATABASE_NAME}, 7200), ], ) - def test_set_timeout(self, parsed_models, expected_timeout, athena_job_helper, monkeypatch) -> None: + def test_start_session( + self, session_status_response, expected_response, spark_session_manager, athena_client + ) -> None: """ - Test case to verify that the `_set_timeout` method of the `AthenaPythonJobHelper` class - returns the expected timeout value. + Test the start_session method of the AthenaJobHelper class. Args: - parsed_models (dict): A dictionary containing the parsed model configuration. - expected_timeout (int): The expected timeout value. - athena_job_helper (AthenaPythonJobHelper): An instance of the `AthenaPythonJobHelper` class. - monkeypatch: A pytest fixture used to mock and patch objects in the test environment. + session_status_response (dict): A dictionary containing the response from the Athena session + creation status. + expected_response (Union[dict, DbtRuntimeError]): The expected response from the start_session method. + athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. + athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. Returns: None """ - monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - response = athena_job_helper.set_timeout() - assert response == expected_timeout + with patch.multiple( + spark_session_manager, + poll_until_session_creation=Mock(return_value=session_status_response), + ), patch.multiple( + athena_client, + get_session_status=Mock(return_value=session_status_response), + start_session=Mock(return_value=session_status_response.get("Status")), + ): + response = spark_session_manager.start_session() + assert response == expected_response @pytest.mark.parametrize( - "parsed_models, expected_polling_interval", + "session_status_response, expected_status", [ - ({"alias": "test_model", "schema": DATABASE_NAME, "config": {"polling_interval": 10}}, 10), - pytest.param( - {"alias": "test_model", "schema": "test_database", "config": {"polling_interval": 0}}, - 0, - marks=pytest.mark.xfail, + ( + { + "SessionId": "test_session_id", + "Status": { + "State": "CREATING", + }, + }, + { + "State": "CREATING", + }, + ), + ( + { + "SessionId": "test_session_id", + "Status": { + "State": "IDLE", + }, + }, + { + "State": "IDLE", + }, ), - ({"alias": "test_model", "schema": DATABASE_NAME}, 5), ], ) - def test_set_polling_interval( - self, parsed_models, expected_polling_interval, athena_job_helper, monkeypatch - ) -> None: - """ - Test method to verify that set_polling_interval() method of AthenaPythonJobHelper - sets the correct polling interval value based on the parsed model configuration. + def test_get_session_status(self, session_status_response, expected_status, spark_session_manager, athena_client): + with patch.multiple(athena_client, get_session_status=Mock(return_value=session_status_response)): + response = spark_session_manager.get_session_status("test_session_id") + assert response == expected_status - Args: - parsed_models (dict): Dictionary containing the parsed configuration model for the Athena job. - expected_polling_interval (int): The expected polling interval value based on the - parsed model configuration. - athena_job_helper (AthenaPythonJobHelper): The instance of AthenaPythonJobHelper to be tested. - monkeypatch: A pytest monkeypatch fixture used to override the parsed model configuration - in AthenaPythonJobHelper. + def test_get_sessions(self): + pass - Returns: - None - """ - monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - response = athena_job_helper.set_polling_interval() - assert response == expected_polling_interval + def test_update_session_locks(self): + pass + + def test_get_session_id(self): + pass + + def test_release_session_lock(self): + pass + + +@pytest.mark.usefixtures("athena_credentials", "athena_client") +class TestAthenaPythonJobHelper: + """ + A class to test the AthenaPythonJobHelper + """ + + @pytest.fixture + def parsed_model(self, request): + return { + "alias": "test_model", + "schema": DATABASE_NAME, + "config": { + "timeout": request.param.get("timeout", 7200), + "polling_interval": request.param.get("polling_interval", 5), + "engine_config": request.param.get( + "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + ), + }, + } + + @pytest.fixture + def athena_spark_session_manager(self, athena_credentials, parsed_model): + return AthenaSparkSessionManager( + athena_credentials, + timeout=parsed_model["config"]["timeout"], + polling_interval=parsed_model["config"]["polling_interval"], + engine_config=parsed_model["config"]["engine_config"], + ) + + @pytest.fixture + def athena_job_helper( + self, athena_credentials, athena_client, athena_spark_session_manager, parsed_model, monkeypatch + ): + mock_job_helper = AthenaPythonJobHelper(parsed_model, athena_credentials) + monkeypatch.setattr(mock_job_helper, "athena_client", athena_client) + monkeypatch.setattr(mock_job_helper, "spark_connection", athena_spark_session_manager) + return mock_job_helper + + # @pytest.mark.parametrize( + # "session_status_response, test_session_id, expected_response", + # [ + # ( + # { + # "SessionId": "test_session_id", + # "Status": { + # "EndDateTime": "number", + # "IdleSinceDateTime": "number", + # "LastModifiedDateTime": "number", + # "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), + # "State": "IDLE", + # "StateChangeReason": "string", + # }, + # }, + # "test_session_id", + # None, + # ), + # ( + # { + # "SessionId": "test_session_id_2", + # "Status": { + # "EndDateTime": "number", + # "IdleSinceDateTime": "number", + # "LastModifiedDateTime": "number", + # "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), + # "State": "TERMINATED", + # "StateChangeReason": "string", + # }, + # }, + # "test_session_id_2", + # None, + # ), + # ], + # ) + # def test_terminate_session( + # self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch + # ) -> None: + # """ + # Test function to check if _terminate_session() method of AthenaPythonJobHelper class correctly + # terminates an Athena session. + + # Args: + # session_status_response: A mock response object containing the current status of the Athena session. + # test_session_id: The session ID of the test Athena session. + # expected_response: The expected response object after the Athena session is terminated. + # athena_job_helper: An instance of the AthenaPythonJobHelper class. + # athena_client: The mocked Athena client object. + # monkeypatch: Pytest monkeypatch object for patching objects and values during testing. + + # Returns: + # None + # """ + + # with patch.multiple( + # athena_client, + # get_session_status=Mock(return_value=session_status_response), + # terminate_session=Mock(return_value=expected_response), + # ), patch.multiple( + # athena_job_helper, + # set_session_id=Mock(return_value=test_session_id), + # set_timeout=Mock(return_value=10), + # ): + # terminate_session_response = athena_job_helper.terminate_session() + # assert terminate_session_response == expected_response @pytest.mark.parametrize( - "parsed_models, expected_engine_config", + "parsed_model, session_status_response, expected_response", [ ( + {"config": {"timeout": 5, "polling_interval": 5}}, { - "alias": "test_model", - "schema": DATABASE_NAME, - "config": { - "engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 44, "DefaultExecutorDpuSize": 1} - }, + "State": "IDLE", }, - {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 44, "DefaultExecutorDpuSize": 1}, + "IDLE", ), pytest.param( - {"alias": "test_model", "schema": "test_database", "config": {"engine_config": 0}}, - 0, + {"config": {"timeout": 5, "polling_interval": 5}}, + { + "State": "FAILED", + }, + "FAILED", marks=pytest.mark.xfail, ), pytest.param( + {"config": {"timeout": 5, "polling_interval": 5}}, { - "alias": "test_wrong_model", - "schema": "test_database", - "config": {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2}}, + "State": "TERMINATED", }, - (), + "TERMINATED", marks=pytest.mark.xfail, ), - ( - {"alias": "test_model", "schema": DATABASE_NAME}, - {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + pytest.param( + {"config": {"timeout": 1, "polling_interval": 5}}, + { + "State": "CREATING", + }, + "CREATING", + marks=pytest.mark.xfail, ), ], + indirect=["parsed_model"], ) - def test_set_engine_config(self, parsed_models, expected_engine_config, athena_job_helper, monkeypatch) -> None: - """ - Test method to verify the `set_engine_config()` method of the AthenaPythonJobHelper class. + def test_poll_session_idle( + self, session_status_response, expected_response, athena_job_helper, athena_spark_session_manager, monkeypatch + ): + with patch.multiple( + athena_spark_session_manager, + get_session_status=Mock(return_value=session_status_response), + get_session_id=Mock(return_value="test_session_id"), + ): - Args: - parsed_models: A dictionary containing the parsed model configuration data. - expected_engine_config: The expected engine configuration that is set. - athena_job_helper: An instance of the AthenaPythonJobHelper class. - monkeypatch: A fixture from the pytest library that allows modifying attributes at runtime for testing. + def mock_sleep(_): + pass - Raises: - KeyError: If the parsed model configuration dictionary does not contain the required key. - - Returns: - None. The method asserts that the actual engine configuration set matches the expected engine configuration. - """ - monkeypatch.setattr(athena_job_helper, "parsed_model", parsed_models) - if parsed_models.get("alias") == "test_wrong_model": - with pytest.raises(KeyError): - athena_job_helper.set_engine_config() - response = athena_job_helper.set_engine_config() - assert response == expected_engine_config + monkeypatch.setattr(time, "sleep", mock_sleep) + poll_response = athena_job_helper.poll_until_session_idle() + assert poll_response == expected_response @pytest.mark.parametrize( - "session_status_response, test_session_id, expected_response", + "parsed_model, execution_status, expected_response", [ ( + {"config": {"timeout": 1, "polling_interval": 5}}, { - "SessionId": "test_session_id", "Status": { - "EndDateTime": "number", - "IdleSinceDateTime": "number", - "LastModifiedDateTime": "number", - "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), - "State": "IDLE", - "StateChangeReason": "string", - }, + "State": "COMPLETED", + } }, - "test_session_id", - None, + "COMPLETED", ), ( + {"config": {"timeout": 1, "polling_interval": 5}}, { - "SessionId": "test_session_id_2", "Status": { - "EndDateTime": "number", - "IdleSinceDateTime": "number", - "LastModifiedDateTime": "number", - "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), - "State": "TERMINATED", - "StateChangeReason": "string", - }, + "State": "FAILED", + } + }, + "FAILED", + ), + pytest.param( + {"config": {"timeout": 1, "polling_interval": 5}}, + { + "Status": { + "State": "RUNNING", + } }, - "test_session_id_2", - None, + "RUNNING", + marks=pytest.mark.xfail, ), ], + indirect=["parsed_model"], ) - def test_terminate_session( - self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch - ) -> None: - """ - Test function to check if _terminate_session() method of AthenaPythonJobHelper class correctly - terminates an Athena session. + def test_poll_execution( + self, + execution_status, + expected_response, + athena_job_helper, + athena_spark_session_manager, + athena_client, + monkeypatch, + ): + with patch.multiple( + athena_spark_session_manager, + get_session_id=Mock(return_value="test_session_id"), + ), patch.multiple( + athena_client, + get_calculation_execution_status=Mock(return_value=execution_status), + ): - Args: - session_status_response: A mock response object containing the current status of the Athena session. - test_session_id: The session ID of the test Athena session. - expected_response: The expected response object after the Athena session is terminated. - athena_job_helper: An instance of the AthenaPythonJobHelper class. - athena_client: The mocked Athena client object. - monkeypatch: Pytest monkeypatch object for patching objects and values during testing. + def mock_sleep(_): + pass - Returns: - None - """ + monkeypatch.setattr(time, "sleep", mock_sleep) + poll_response = athena_job_helper.poll_until_execution_completion("test_calculation_id") + assert poll_response == expected_response + @pytest.mark.parametrize( + "parsed_model, test_calculation_execution_id, test_calculation_execution, test_calculation_execution_status", + [ + pytest.param( + {"config": {"timeout": 1, "polling_interval": 5}}, + {"CalculationExecutionId": "test_execution_id"}, + {"Result": {"ResultS3Uri": "test_results_s3_uri"}}, + {"Status": {"State": "COMPLETED"}}, + ), + pytest.param( + {"config": {"timeout": 1, "polling_interval": 5}}, + {"CalculationExecutionId": "test_execution_id"}, + {"Result": {"ResultS3Uri": None}}, + {"Status": {"State": "FAILED"}}, + ), + pytest.param( + {"config": {"timeout": 1, "polling_interval": 5}}, + {}, + {"Result": {"ResultS3Uri": None}}, + {"Status": {"State": "FAILED"}}, + marks=pytest.mark.xfail, + ), + ], + indirect=["parsed_model"], + ) + def test_submission( + self, + test_calculation_execution_id, + test_calculation_execution, + test_calculation_execution_status, + athena_job_helper, + athena_spark_session_manager, + athena_client, + ): with patch.multiple( + athena_spark_session_manager, + get_session_id=Mock(return_value="test_session_id"), + release_session_lock=Mock(), + ), patch.multiple( athena_client, - get_session_status=Mock(return_value=session_status_response), - terminate_session=Mock(return_value=expected_response), + start_calculation_execution=Mock(return_value=test_calculation_execution_id), + get_calculation_execution=Mock(return_value=test_calculation_execution), + get_calculation_execution_status=Mock(return_value=test_calculation_execution_status), ), patch.multiple( - athena_job_helper, - set_session_id=Mock(return_value=test_session_id), - set_timeout=Mock(return_value=10), + athena_job_helper, poll_until_session_idle=Mock(return_value="IDLE") ): - terminate_session_response = athena_job_helper.terminate_session() - assert terminate_session_response == expected_response - - def test_poll_session_creation(self): - pass - - def test_poll_execution(self): - pass - - def test_submission(self): - pass + result = athena_job_helper.submit("hello world") + assert result == test_calculation_execution["Result"]["ResultS3Uri"] From e61c4d930659ed90d848a1e3b0fe971a20671373 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 5 May 2023 18:34:41 -0300 Subject: [PATCH 45/75] Fix flake8 tests --- tests/unit/test_python_submissions.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 67fab4c9..5c439808 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,9 +1,7 @@ import time -from datetime import datetime from unittest.mock import Mock, patch import pytest -import pytz from dbt.adapters.athena.python_submissions import ( AthenaPythonJobHelper, @@ -323,7 +321,8 @@ def athena_job_helper( # ], # ) # def test_terminate_session( - # self, session_status_response, test_session_id, expected_response, athena_job_helper, athena_client, monkeypatch + # self, session_status_response, test_session_id, expected_response, athena_job_helper, + # athena_client, monkeypatch # ) -> None: # """ # Test function to check if _terminate_session() method of AthenaPythonJobHelper class correctly From 2dc8afd0a429c25732d91f0d0cab7bd587838580 Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 7 May 2023 11:34:30 -0300 Subject: [PATCH 46/75] Added tests for listing and updating sessions --- .python-version | 1 + tests/unit/test_python_submissions.py | 82 +++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..1281604a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.7 diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 5c439808..12e5e7fc 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,8 +1,10 @@ import time +import uuid from unittest.mock import Mock, patch import pytest +from dbt.adapters.athena import python_submissions from dbt.adapters.athena.python_submissions import ( AthenaPythonJobHelper, AthenaSparkSessionConfig, @@ -234,11 +236,83 @@ def test_get_session_status(self, session_status_response, expected_status, spar response = spark_session_manager.get_session_status("test_session_id") assert response == expected_status - def test_get_sessions(self): - pass + @pytest.mark.parametrize( + "list_sessions_response, session_locks", + [ + ( + [ + { + "Description": "string", + "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, + "NotebookVersion": "string", + "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", + "Status": { + "State": "string", + "StateChangeReason": "string", + }, + }, + { + "Description": "string", + "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, + "NotebookVersion": "string", + "SessionId": "39cb8fc0-f855-4b67-91f1-81f068499071", + "Status": { + "State": "string", + "StateChangeReason": "string", + }, + }, + ], + {"test_session_id": None}, + ), + ( + [], + {}, + ), + ( + [ + { + "Description": "string", + "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, + "NotebookVersion": "string", + "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", + "Status": { + "State": "string", + "StateChangeReason": "string", + }, + }, + ], + {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): "lock"}, + ), + ], + ) + def test_get_sessions( + self, list_sessions_response, session_locks, spark_session_manager, athena_client, monkeypatch + ): + monkeypatch.setattr(python_submissions, "session_locks", session_locks) + with patch.multiple( + spark_session_manager, + list_sessions=Mock(return_value=list_sessions_response), + ): + sessions = spark_session_manager.get_sessions() + assert sessions == [uuid.UUID(response["SessionId"]) for response in list_sessions_response] - def test_update_session_locks(self): - pass + @pytest.mark.parametrize( + "get_session_response, current_session_locks", + [([], {}), ([uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], {})], + ) + def test_update_session_locks( + self, get_session_response, current_session_locks, spark_session_manager, monkeypatch + ): + print(get_session_response) + monkeypatch.setattr(python_submissions, "session_locks", current_session_locks) + with patch.multiple( + spark_session_manager, + get_sessions=Mock(return_value=get_session_response), + ): + spark_session_manager.update_session_locks() + for session in get_session_response: + assert session in python_submissions.session_locks.keys() + assert type(python_submissions.session_locks[session]) is not None def test_get_session_id(self): pass From f281a0622d6ebe311becc29fba7a525d188c7e5b Mon Sep 17 00:00:00 2001 From: Avinash-1394 <43074786+Avinash-1394@users.noreply.github.com> Date: Mon, 8 May 2023 20:26:34 -0300 Subject: [PATCH 47/75] Added docs to tests --- dbt/adapters/athena/python_submissions.py | 13 ++ tests/unit/test_python_submissions.py | 228 ++++++++++++++-------- 2 files changed, 159 insertions(+), 82 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 93e67d8a..a1645457 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -108,6 +108,19 @@ def get_sessions(self) -> List[uuid.UUID]: return [uuid.UUID(session) for session in new_sessions] def update_session_locks(self) -> None: + """ + Update session locks for each session. + + This function iterates over the existing sessions and ensures that a session lock is created for each session. + If a session lock already exists, it is left unchanged. After updating the session locks, + a debug log is generated to display the updated session locks. + + Args: + self: The instance of the class. + + Returns: + None + """ for session_uuid in self.get_sessions(): session_locks.setdefault(session_uuid, threading.Lock()) logger.debug(f"Updated session locks: {session_locks}") diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 12e5e7fc..37675c8e 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -22,6 +22,23 @@ class TestAthenaSparkSessionConfig: @pytest.fixture def spark_config(self, request): + """ + Fixture for providing Spark configuration parameters. + + This fixture returns a dictionary containing the Spark configuration parameters. The parameters can be + customized using the `request.param` object. The default values are: + - `timeout`: 7200 seconds + - `polling_interval`: 5 seconds + - `engine_config`: {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + + Args: + self: The test class instance. + request: The pytest request object. + + Returns: + dict: The Spark configuration parameters. + + """ return { "timeout": request.param.get("timeout", 7200), "polling_interval": request.param.get("polling_interval", 5), @@ -32,6 +49,14 @@ def spark_config(self, request): @pytest.fixture def spark_config_helper(self, spark_config): + """Fixture for testing AthenaSparkSessionConfig class. + + Args: + spark_config (dict): Fixture for default spark config. + + Returns: + AthenaSparkSessionConfig: An instance of AthenaSparkSessionConfig class. + """ return AthenaSparkSessionConfig(spark_config) @pytest.mark.parametrize( @@ -91,6 +116,23 @@ class TestAthenaSparkSessionManager: @pytest.fixture def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): + """ + Fixture for creating a mock Spark session manager. + + This fixture creates an instance of AthenaSparkSessionManager with the provided Athena credentials, + timeout, polling interval, and engine configuration. It then patches the Athena client of the manager + with the provided `athena_client` object. The fixture returns the mock Spark session manager. + + Args: + self: The test class instance. + athena_credentials: The Athena credentials. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Returns: + The mock Spark session manager. + + """ mock_session_manager = AthenaSparkSessionManager( athena_credentials, timeout=10, @@ -232,6 +274,22 @@ def test_start_session( ], ) def test_get_session_status(self, session_status_response, expected_status, spark_session_manager, athena_client): + """ + Test the get_session_status function. + + Args: + self: The test class instance. + session_status_response (dict): The response from get_session_status. + expected_status (dict): The expected session status. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + + Returns: + None + + Raises: + AssertionError: If the retrieved session status is not correct. + """ with patch.multiple(athena_client, get_session_status=Mock(return_value=session_status_response)): response = spark_session_manager.get_session_status("test_session_id") assert response == expected_status @@ -242,23 +300,15 @@ def test_get_session_status(self, session_status_response, expected_status, spar ( [ { - "Description": "string", - "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, - "NotebookVersion": "string", "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", "Status": { "State": "string", - "StateChangeReason": "string", }, }, { - "Description": "string", - "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, - "NotebookVersion": "string", "SessionId": "39cb8fc0-f855-4b67-91f1-81f068499071", "Status": { "State": "string", - "StateChangeReason": "string", }, }, ], @@ -271,13 +321,9 @@ def test_get_session_status(self, session_status_response, expected_status, spar ( [ { - "Description": "string", - "EngineVersion": {"EffectiveEngineVersion": "string", "SelectedEngineVersion": "string"}, - "NotebookVersion": "string", "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", "Status": { "State": "string", - "StateChangeReason": "string", }, }, ], @@ -288,6 +334,23 @@ def test_get_session_status(self, session_status_response, expected_status, spar def test_get_sessions( self, list_sessions_response, session_locks, spark_session_manager, athena_client, monkeypatch ): + """ + Test the get_sessions function. + + Args: + self: The test class instance. + list_sessions_response (list): The response from list_sessions. + session_locks (dict): The session locks. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Returns: + None + + Raises: + AssertionError: If the retrieved sessions are not correct. + """ monkeypatch.setattr(python_submissions, "session_locks", session_locks) with patch.multiple( spark_session_manager, @@ -303,7 +366,19 @@ def test_get_sessions( def test_update_session_locks( self, get_session_response, current_session_locks, spark_session_manager, monkeypatch ): - print(get_session_response) + """ + Test the update_session_locks function. + + Args: + self: The test class instance. + get_session_response (list): The response from get_sessions. + current_session_locks (dict): The current session locks. + spark_session_manager: The Spark session manager object. + monkeypatch: The monkeypatch object for mocking. + + Raises: + AssertionError: If the session locks are not updated correctly. + """ monkeypatch.setattr(python_submissions, "session_locks", current_session_locks) with patch.multiple( spark_session_manager, @@ -317,8 +392,64 @@ def test_update_session_locks( def test_get_session_id(self): pass - def test_release_session_lock(self): - pass + @pytest.mark.parametrize( + "test_session_id, get_session_status_response, current_session_locks, terminate_session_response", + [ + ( + "106d7aca-4b3f-468d-a81d-308120e7f73c", + { + "State": "string", + }, + {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, + {"State": "TERMINATED"}, + ), + ( + "106d7aca-4b3f-468d-a81d-308120e7f73c", + { + "State": "string", + }, + {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, + {"State": "CREATED"}, + ), + ], + ) + def test_release_session_lock( + self, + test_session_id, + get_session_status_response, + current_session_locks, + terminate_session_response, + spark_session_manager, + athena_client, + monkeypatch, + ): + """ + Test the release_session_lock function. + + Args: + self: The test class instance. + test_session_id (str): The ID of the test session. + get_session_status_response (dict): The response from get_session_status. + current_session_locks (dict): The current session locks. + terminate_session_response (dict): The response from terminate_session. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Raises: + AssertionError: If the session lock is not released correctly. + """ + monkeypatch.setattr(python_submissions, "session_locks", current_session_locks) + with patch.multiple( + spark_session_manager, + get_session_status=Mock(return_value=get_session_status_response), + ), patch.multiple( + athena_client, + terminate_session=Mock(return_value=terminate_session_response), + ): + spark_session_manager.release_session_lock(test_session_id) + assert uuid.UUID(test_session_id) in python_submissions.session_locks.keys() + assert type(python_submissions.session_locks[uuid.UUID(test_session_id)]) is not None @pytest.mark.usefixtures("athena_credentials", "athena_client") @@ -359,73 +490,6 @@ def athena_job_helper( monkeypatch.setattr(mock_job_helper, "spark_connection", athena_spark_session_manager) return mock_job_helper - # @pytest.mark.parametrize( - # "session_status_response, test_session_id, expected_response", - # [ - # ( - # { - # "SessionId": "test_session_id", - # "Status": { - # "EndDateTime": "number", - # "IdleSinceDateTime": "number", - # "LastModifiedDateTime": "number", - # "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), - # "State": "IDLE", - # "StateChangeReason": "string", - # }, - # }, - # "test_session_id", - # None, - # ), - # ( - # { - # "SessionId": "test_session_id_2", - # "Status": { - # "EndDateTime": "number", - # "IdleSinceDateTime": "number", - # "LastModifiedDateTime": "number", - # "StartDateTime": datetime(2023, 4, 21, 0, tzinfo=pytz.timezone("UTC")), - # "State": "TERMINATED", - # "StateChangeReason": "string", - # }, - # }, - # "test_session_id_2", - # None, - # ), - # ], - # ) - # def test_terminate_session( - # self, session_status_response, test_session_id, expected_response, athena_job_helper, - # athena_client, monkeypatch - # ) -> None: - # """ - # Test function to check if _terminate_session() method of AthenaPythonJobHelper class correctly - # terminates an Athena session. - - # Args: - # session_status_response: A mock response object containing the current status of the Athena session. - # test_session_id: The session ID of the test Athena session. - # expected_response: The expected response object after the Athena session is terminated. - # athena_job_helper: An instance of the AthenaPythonJobHelper class. - # athena_client: The mocked Athena client object. - # monkeypatch: Pytest monkeypatch object for patching objects and values during testing. - - # Returns: - # None - # """ - - # with patch.multiple( - # athena_client, - # get_session_status=Mock(return_value=session_status_response), - # terminate_session=Mock(return_value=expected_response), - # ), patch.multiple( - # athena_job_helper, - # set_session_id=Mock(return_value=test_session_id), - # set_timeout=Mock(return_value=10), - # ): - # terminate_session_response = athena_job_helper.terminate_session() - # assert terminate_session_response == expected_response - @pytest.mark.parametrize( "parsed_model, session_status_response, expected_response", [ From 0038628b652d25095ea63b0bb2df5e7e0f34cdc1 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Sun, 28 May 2023 16:29:12 -0300 Subject: [PATCH 48/75] Split the class objects into separate modules. Handled threading deadlocks. --- dbt/adapters/athena/config.py | 59 +++ dbt/adapters/athena/constants.py | 2 +- dbt/adapters/athena/impl.py | 2 +- dbt/adapters/athena/python_submissions.py | 313 ++---------- dbt/adapters/athena/session.py | 204 ++++++++ .../macros/adapters/python_submissions.sql | 2 +- tests/unit/test_config.py | 96 +++- tests/unit/test_python_submissions.py | 467 +----------------- tests/unit/test_session.py | 341 ++++++++++++- 9 files changed, 750 insertions(+), 736 deletions(-) diff --git a/dbt/adapters/athena/config.py b/dbt/adapters/athena/config.py index 5e426f1c..8e5aff39 100644 --- a/dbt/adapters/athena/config.py +++ b/dbt/adapters/athena/config.py @@ -3,9 +3,68 @@ import pkg_resources from botocore import config +from dbt.adapters.athena.constants import LOGGER + +DEFAULT_POLLING_INTERVAL = 5 +DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} +DEFAULT_TIMEOUT = 60 * 60 * 2 + @lru_cache() def get_boto3_config() -> config.Config: return config.Config( user_agent_extra="dbt-athena-community/" + pkg_resources.get_distribution("dbt-athena-community").version ) + + +class AthenaSparkSessionConfig: + """ + A helper class to manage Athena Spark Session Configuration. + """ + + def __init__(self, config: dict) -> None: + self.config = config + + def set_timeout(self) -> int: + """ + Get the timeout value. + + This function retrieves the timeout value from the parsed model's configuration. If the timeout value + is not defined, it falls back to the default timeout value. If the retrieved timeout value is less than or + equal to 0, a ValueError is raised as timeout must be a positive integer. + + Returns: + int: The timeout value in seconds. + + Raises: + ValueError: If the timeout value is not a positive integer. + + """ + timeout = self.config.get("timeout", DEFAULT_TIMEOUT) + if not isinstance(timeout, int): + raise TypeError("Timeout must be an integer") + if timeout <= 0: + raise ValueError("Timeout must be a positive integer") + LOGGER.debug(f"Setting timeout: {timeout}") + return timeout + + def set_polling_interval(self) -> float: + polling_interval = self.config.get("polling_interval", DEFAULT_POLLING_INTERVAL) + if not isinstance(polling_interval, int) or polling_interval <= 0: + raise ValueError("Polling_interval must be a positive integer") + LOGGER.debug(f"Setting polling_interval: {polling_interval}") + return polling_interval + + def set_engine_config(self) -> dict: + engine_config = self.config.get("engine_config", DEFAULT_ENGINE_CONFIG) + if not isinstance(engine_config, dict): + raise TypeError("Engine configuration has to be of type dict") + + expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} + if set(engine_config.keys()) != expected_keys: + raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") + + if engine_config["MaxConcurrentDpus"] == 1: + raise KeyError("The lowest value supported for MaxConcurrentDpus is 2") + LOGGER.debug(f"Setting engine configuration: {engine_config}") + return engine_config diff --git a/dbt/adapters/athena/constants.py b/dbt/adapters/athena/constants.py index bfedb302..bfd6e4b0 100644 --- a/dbt/adapters/athena/constants.py +++ b/dbt/adapters/athena/constants.py @@ -1,3 +1,3 @@ from dbt.events import AdapterLogger -LOGGER = AdapterLogger("Athena") +LOGGER = AdapterLogger(__name__) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index b21c390e..48f982b1 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -630,7 +630,7 @@ def persist_docs_to_glue( glue_client.update_table(DatabaseName=relation.schema, TableInput=updated_table) def generate_python_submission_response(self, submission_result: Any) -> AdapterResponse: - if submission_result is None: + if not submission_result: return AdapterResponse(_message="ERROR") return AdapterResponse(_message="OK") diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index a1645457..e4dc2b2e 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -1,259 +1,17 @@ -import threading import time -import uuid -from datetime import datetime, timedelta, timezone from functools import lru_cache -from typing import Dict, List +from typing import Any, Dict -import boto3 import botocore +from dbt.adapters.athena.config import AthenaSparkSessionConfig from dbt.adapters.athena.connections import AthenaCredentials +from dbt.adapters.athena.constants import LOGGER +from dbt.adapters.athena.session import AthenaSparkSessionManager from dbt.adapters.base import PythonJobHelper -from dbt.events import AdapterLogger from dbt.exceptions import DbtRuntimeError -DEFAULT_POLLING_INTERVAL = 5 -DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} SUBMISSION_LANGUAGE = "python" -DEFAULT_TIMEOUT = 60 * 60 * 2 -DEFAULT_SESSION_COUNT = 16 - -logger = AdapterLogger("Athena") -session_locks = {} - - -class AthenaSparkSessionConfig: - """ - A helper class to manage Athena Spark Session Configuration. - """ - - def __init__(self, config: dict): - self.config = config - - def set_timeout(self) -> int: - """ - Get the timeout value. - - This function retrieves the timeout value from the parsed model's configuration. If the timeout value - is not defined, it falls back to the default timeout value. If the retrieved timeout value is less than or - equal to 0, a ValueError is raised as timeout must be a positive integer. - - Returns: - int: The timeout value in seconds. - - Raises: - ValueError: If the timeout value is not a positive integer. - - """ - timeout = self.config.get("timeout", DEFAULT_TIMEOUT) - if not isinstance(timeout, int): - raise TypeError("Timeout must be an integer") - if timeout <= 0: - raise ValueError("Timeout must be a positive integer") - logger.debug(f"Setting timeout: {timeout}") - return timeout - - def set_polling_interval(self) -> int: - polling_interval = self.config.get("polling_interval", DEFAULT_POLLING_INTERVAL) - if not isinstance(polling_interval, int) or polling_interval <= 0: - raise ValueError("polling_interval must be a positive integer") - logger.debug(f"Setting polling_interval: {polling_interval}") - return polling_interval - - def set_engine_config(self) -> dict: - engine_config = self.config.get("engine_config", DEFAULT_ENGINE_CONFIG) - if not isinstance(engine_config, dict): - raise TypeError("engine configuration has to be of type dict") - - expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} - if set(engine_config.keys()) != expected_keys: - raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") - return engine_config - - -class AthenaSparkSessionManager: - """ - A helper class to manage Athena Spark Sessions. - """ - - def __init__(self, credentials: str, **kwargs): - self.credentials = credentials - self.timeout = kwargs.get("timeout") - self.polling_interval = kwargs.get("polling_interval") - self.engine_config = kwargs.get("engine_config") - self.lock = threading.Lock() - self.athena_client = self.get_athena_client() - - def get_athena_client(self): - """ - Get the AWS Athena client. - - This function returns an AWS Athena client object that can be used to interact with the Athena service. - The client is created using the region name and profile name provided during object instantiation. - - Returns: - Any: The Athena client object. - - """ - return boto3.session.Session( - region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name - ).client("athena") - - def get_sessions(self) -> List[uuid.UUID]: - sessions = self.list_sessions() - existing_sessions = set(session_locks.keys()) - new_sessions = [session["SessionId"] for session in sessions if session["SessionId"] not in existing_sessions] - logger.debug(f"Setting sessions: {new_sessions}") - return [uuid.UUID(session) for session in new_sessions] - - def update_session_locks(self) -> None: - """ - Update session locks for each session. - - This function iterates over the existing sessions and ensures that a session lock is created for each session. - If a session lock already exists, it is left unchanged. After updating the session locks, - a debug log is generated to display the updated session locks. - - Args: - self: The instance of the class. - - Returns: - None - """ - for session_uuid in self.get_sessions(): - session_locks.setdefault(session_uuid, threading.Lock()) - logger.debug(f"Updated session locks: {session_locks}") - - def get_session_id(self) -> str: - """ - Get the session ID. - - This function retrieves the session ID from the stored session information. If session information - is not available, a new session is started and its session ID is returned. - - Returns: - str: The session ID. - - """ - self.update_session_locks() - with self.lock: - for session_uuid, lock in session_locks.items(): - if not lock.locked(): - logger.debug(f"Locking existing session: {session_uuid}") - lock.acquire() - return session_uuid - logger.debug("All sessions are currently locked. Starting new session.") - session_uuid = uuid.UUID(self.start_session()) - with self.lock: - logger.debug(f"Locking new session: {session_uuid}") - session_locks[session_uuid] = threading.Lock() - session_locks[session_uuid].acquire() - return session_uuid - - def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = "IDLE") -> dict: - """ - List athena spark sessions. - - This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. - It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions - are found or if an error occurs, None is returned. - - Returns: - dict: The session information dictionary if an idle session is found, None otherwise. - - """ - response = self.athena_client.list_sessions( - WorkGroup=self.credentials.spark_work_group, MaxResults=max_results, StateFilter=state - ) - if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: - return {} - return response.get("Sessions") - - def start_session(self) -> dict: - """ - Start an Athena session. - - This function sends a request to the Athena service to start a session in the specified Spark workgroup. - It configures the session with specific engine configurations. If the session state is not IDLE, the function - polls until the session creation is complete. The response containing session information is returned. - - Returns: - dict: The session information dictionary. - - """ - if len(session_locks) >= DEFAULT_SESSION_COUNT: - # Raise this exception but also poll until a session is free and assign that - raise Exception( - f"""Maximum session count: {DEFAULT_SESSION_COUNT} reached. - Cannot start new spark session.""" - ) - response = self.athena_client.start_session( - WorkGroup=self.credentials.spark_work_group, - EngineConfiguration=self.engine_config, - ) - if response["State"] != "IDLE": - self.poll_until_session_creation(response["SessionId"]) - return response["SessionId"] - - def poll_until_session_creation(self, session_id): - """ - Polls the status of an Athena session creation until it is completed or reaches the timeout. - - Args: - session_id (str): The ID of the session being created. - - Returns: - str: The final status of the session, which will be "IDLE" if the session creation is successful. - - Raises: - DbtRuntimeError: If the session creation fails, is terminated, or degrades during polling. - DbtRuntimeError: If the session does not become IDLE within the specified timeout. - - """ - polling_interval = self.polling_interval - while True: - creation_status = self.get_session_status(session_id)["State"] - if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: - raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") - elif creation_status == "IDLE": - return creation_status - time.sleep(polling_interval) - polling_interval *= 2 - if polling_interval > self.timeout: - raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") - - def release_session_lock(self, session_id) -> None: - """ - Terminate the current Athena session. - - This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the - configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the - duration since the session start time exceeds the timeout period, the session is terminated. The session ID is - used to terminate the session via the Athena client. - - Returns: - dict: The response from the Athena client after terminating the session. - - """ - session_status = self.get_session_status(session_id) - if session_status["State"] in ["IDLE", "BUSY"] and ( - session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) - ): - logger.debug(f"Terminating session: {session_id}") - self.athena_client.terminate_session(SessionId=session_id) - with self.lock: - logger.debug(f"Releasing lock for session: {session_id}") - session_locks[uuid.UUID(session_id)].release() - - def get_session_status(self, session_id) -> dict: - """ - Get the session status. - - Returns: - str: The status of the session - """ - return self.athena_client.get_session_status(SessionId=session_id)["Status"] class AthenaPythonJobHelper(PythonJobHelper): @@ -288,31 +46,31 @@ class AthenaPythonJobHelper(PythonJobHelper): def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.config = AthenaSparkSessionConfig(parsed_model.get("config", {})) self.spark_connection = AthenaSparkSessionManager( - credentials, timeout=self.timeout, polling_interval=self.polling_interval, engine_config=self.engine_config + credentials, self.timeout, self.polling_interval, self.engine_config ) self.athena_client = self.spark_connection.get_athena_client() @property - @lru_cache() - def timeout(self): + @lru_cache(maxsize=1) + def timeout(self) -> int: return self.config.set_timeout() @property - @lru_cache() + @lru_cache(maxsize=1) def session_id(self) -> str: return str(self.spark_connection.get_session_id()) @property - @lru_cache() - def polling_interval(self): + @lru_cache(maxsize=1) + def polling_interval(self) -> float: return self.config.set_polling_interval() @property - @lru_cache() - def engine_config(self): + @lru_cache(maxsize=1) + def engine_config(self) -> Dict[str, int]: return self.config.set_engine_config() - def get_current_session_status(self) -> str: + def get_current_session_status(self) -> Dict[str, Any]: """ Get the current session status. @@ -321,14 +79,14 @@ def get_current_session_status(self) -> str: """ return self.spark_connection.get_session_status(self.session_id) - def poll_until_session_idle(self): + def poll_until_session_idle(self) -> None: polling_interval = self.polling_interval while True: session_status = self.get_current_session_status()["State"] - if session_status == "IDLE": - return session_status if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: raise DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") + if session_status == "IDLE": + break time.sleep(polling_interval) polling_interval *= 2 if polling_interval > self.timeout: @@ -361,32 +119,30 @@ def submit(self, compiled_code: str) -> dict: )["CalculationExecutionId"] break except botocore.exceptions.ClientError as ce: - logger.exception(f"Encountered client error: {ce}") + LOGGER.exception(f"Encountered client error: {ce}") if ( ce.response["Error"]["Code"] == "InvalidRequestException" and "Session is in the BUSY state; needs to be IDLE to accept Calculations." in ce.response["Error"]["Message"] ): - logger.exception("Going to poll until session is IDLE") + LOGGER.exception("Going to poll until session is IDLE") self.poll_until_session_idle() except Exception as e: raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") - try: - execution_status = self.poll_until_execution_completion(calculation_execution_id) - logger.debug(f"Received execution status {execution_status}") - if execution_status == "COMPLETED": - result_s3_uri = self.athena_client.get_calculation_execution( - CalculationExecutionId=calculation_execution_id - )["Result"]["ResultS3Uri"] - return result_s3_uri - else: - raise DbtRuntimeError(f"python model run ended in state {execution_status}") - except Exception as e: - logger.error(f"Unable to poll execution status: Got: {e}") - finally: - self.spark_connection.release_session_lock(self.session_id) + execution_status = self.poll_until_execution_completion(calculation_execution_id) + LOGGER.debug(f"Received execution status {execution_status}") + if execution_status == "COMPLETED": + try: + result = self.athena_client.get_calculation_execution(CalculationExecutionId=calculation_execution_id)[ + "Result" + ] + except Exception as e: + LOGGER.error(f"Unable to poll execution status: Got: {e}") + result = {} + self.spark_connection.release_session_lock(self.session_id) + return result - def poll_until_execution_completion(self, calculation_execution_id): + def poll_until_execution_completion(self, calculation_execution_id) -> str: """ Poll the status of a calculation execution until it is completed, failed, or cancelled. @@ -412,7 +168,12 @@ def poll_until_execution_completion(self, calculation_execution_id): execution_status = self.athena_client.get_calculation_execution_status( CalculationExecutionId=calculation_execution_id )["Status"]["State"] - if execution_status in ["COMPLETED", "FAILED", "CANCELLED"]: + if execution_status in ["FAILED", "CANCELLED"]: + raise DbtRuntimeError( + f"""Execution {calculation_execution_id} did not complete successfully. + Got: {execution_status} status.""" + ) + if execution_status == "COMPLETED": return execution_status time.sleep(polling_interval) polling_interval *= 2 diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 60c86fba..138e0d61 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -1,6 +1,18 @@ +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List +from uuid import UUID + +import boto3 import boto3.session +from dbt.adapters.athena.constants import LOGGER from dbt.contracts.connection import Connection +from dbt.exceptions import DbtRuntimeError + +DEFAULT_SESSION_COUNT = 4 +spark_session_locks: Dict[UUID, threading.Lock] = {} def get_boto3_session(connection: Connection) -> boto3.session.Session: @@ -10,3 +22,195 @@ def get_boto3_session(connection: Connection) -> boto3.session.Session: region_name=connection.credentials.region_name, profile_name=connection.credentials.aws_profile_name, ) + + +class AthenaSparkSessionManager: + """ + A helper class to manage Athena Spark Sessions. + """ + + def __init__(self, credentials, timeout: int, polling_interval: float, engine_config: Dict[str, int]): + self.credentials = credentials + self.timeout = timeout + self.polling_interval = polling_interval + self.engine_config = engine_config + self.lock = threading.Lock() + self.athena_client = self.get_athena_client() + + def get_athena_client(self): + """ + Get the AWS Athena client. + + This function returns an AWS Athena client object that can be used to interact with the Athena service. + The client is created using the region name and profile name provided during object instantiation. + + Returns: + Any: The Athena client object. + + """ + return boto3.session.Session( + region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name + ).client("athena") + + def get_new_sessions(self) -> List[UUID]: + """ + Retrieves a list of new sessions by subtracting the existing sessions from the complete session list. + + Returns: + List[UUID]: A list of new session UUIDs. + + """ + sessions = self.list_sessions() + existing_sessions = set(spark_session_locks.keys()) + new_sessions = [session for session in sessions if session not in existing_sessions] + LOGGER.debug(f"Setting sessions: {new_sessions}") + return new_sessions + + def update_spark_session_locks(self) -> None: + """ + Update session locks for each session. + + This function iterates over the existing sessions and ensures that a session lock is created for each session. + If a session lock already exists, it is left unchanged. After updating the session locks, + a debug log is generated to display the updated session locks. + + Args: + self: The instance of the class. + + Returns: + None + """ + for session_uuid in self.get_new_sessions(): + spark_session_locks.setdefault(session_uuid, threading.Lock()) + LOGGER.debug(f"Updated session locks: {spark_session_locks}") + + def get_session_id(self) -> UUID: + """ + Get the session ID. + + This function retrieves the session ID from the stored session information. If session information + is not available, a new session is started and its session ID is returned. + + Returns: + str: The session ID. + + """ + polling_interval = self.polling_interval + self.update_spark_session_locks() + while True: + for session_uuid, lock in spark_session_locks.items(): + if not lock.locked(): + LOGGER.debug(f"Locking existing session: {session_uuid}") + lock.acquire(blocking=False) + return session_uuid + LOGGER.debug( + f"""All available spark sessions: {spark_session_locks.keys()} are locked. + Going to sleep: {polling_interval} seconds.""" + ) + time.sleep(polling_interval) + polling_interval *= 2 + + def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = "IDLE") -> List[UUID]: + """ + List athena spark sessions. + + This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. + It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions + are found or if an error occurs, None is returned. + + Returns: + List[UUID]: A list of session UUIDs if an idle sessions are found, else start a new session and return + the list of the returned session id. + + """ + response = self.athena_client.list_sessions( + WorkGroup=self.credentials.spark_work_group, MaxResults=max_results, StateFilter=state + ) + if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: + if len(spark_session_locks) < DEFAULT_SESSION_COUNT: + return [self.start_session()] + LOGGER.warning( + f"""Maximum spark session count: {DEFAULT_SESSION_COUNT} reached. + Cannot start new spark session.""" + ) + return [UUID(session_string["SessionId"]) for session_string in response.get("Sessions")] + + def start_session(self) -> UUID: + """ + Start an Athena session. + + This function sends a request to the Athena service to start a session in the specified Spark workgroup. + It configures the session with specific engine configurations. If the session state is not IDLE, the function + polls until the session creation is complete. The response containing session information is returned. + + Returns: + dict: The session information dictionary. + + """ + response = self.athena_client.start_session( + WorkGroup=self.credentials.spark_work_group, + EngineConfiguration=self.engine_config, + ) + if response["State"] != "IDLE": + self.poll_until_session_creation(response["SessionId"]) + return UUID(response["SessionId"]) + + def poll_until_session_creation(self, session_id) -> None: + """ + Polls the status of an Athena session creation until it is completed or reaches the timeout. + + Args: + session_id (str): The ID of the session being created. + + Returns: + str: The final status of the session, which will be "IDLE" if the session creation is successful. + + Raises: + DbtRuntimeError: If the session creation fails, is terminated, or degrades during polling. + DbtRuntimeError: If the session does not become IDLE within the specified timeout. + + """ + polling_interval = self.polling_interval + while True: + creation_status = self.get_session_status(session_id)["State"] + if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: + raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") + elif creation_status == "IDLE": + LOGGER.debug(f"Session: {session_id} created") + break + time.sleep(polling_interval) + polling_interval *= 2 + if polling_interval > self.timeout: + raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") + + def release_session_lock(self, session_id: str) -> None: + """ + Terminate the current Athena session. + + This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the + configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the + duration since the session start time exceeds the timeout period, the session is terminated. The session ID is + used to terminate the session via the Athena client. + + Returns: + None + + """ + session_status = self.get_session_status(session_id) + if session_status["State"] in ["IDLE", "BUSY"] and ( + session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) + ): + LOGGER.debug(f"Terminating session: {session_id}") + self.athena_client.terminate_session(SessionId=session_id) + with self.lock: + LOGGER.debug(f"Releasing lock for session: {session_id}") + spark_session_locks[UUID(session_id)].release() + + def get_session_status(self, session_id) -> Dict[str, Any]: + """ + Get the session status. + + Returns: + str: The status of the session + """ + return self.athena_client.get_session_status(SessionId=session_id)["Status"] diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index f0b31215..bf389e4a 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -78,7 +78,7 @@ def get_spark_df(identifier): So the override removes the catalog component and only provides the schema and identifer to spark.table() """ - return spark.table(identifier.split(".", 1)[1]) + return spark.table(".".join(identifier.split(".")[1:]).replace('"', '')) class SparkdbtObj(dbtObj): def __init__(self): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 28b33ab1..aebcc8fe 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,8 +1,9 @@ from unittest.mock import Mock import pkg_resources +import pytest -from dbt.adapters.athena.config import get_boto3_config +from dbt.adapters.athena.config import AthenaSparkSessionConfig, get_boto3_config class TestConfig: @@ -11,3 +12,96 @@ def test_get_boto3_config(self): get_boto3_config.cache_clear() config = get_boto3_config() assert config._user_provided_options["user_agent_extra"] == "dbt-athena-community/2.4.6" + + +class TestAthenaSparkSessionConfig: + """ + A class to test AthenaSparkSessionConfig + """ + + @pytest.fixture + def spark_config(self, request): + """ + Fixture for providing Spark configuration parameters. + + This fixture returns a dictionary containing the Spark configuration parameters. The parameters can be + customized using the `request.param` object. The default values are: + - `timeout`: 7200 seconds + - `polling_interval`: 5 seconds + - `engine_config`: {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + + Args: + self: The test class instance. + request: The pytest request object. + + Returns: + dict: The Spark configuration parameters. + + """ + return { + "timeout": request.param.get("timeout", 7200), + "polling_interval": request.param.get("polling_interval", 5), + "engine_config": request.param.get( + "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + ), + } + + @pytest.fixture + def spark_config_helper(self, spark_config): + """Fixture for testing AthenaSparkSessionConfig class. + + Args: + spark_config (dict): Fixture for default spark config. + + Returns: + AthenaSparkSessionConfig: An instance of AthenaSparkSessionConfig class. + """ + return AthenaSparkSessionConfig(spark_config) + + @pytest.mark.parametrize( + "spark_config", + [ + {"timeout": 5}, + {"timeout": 10}, + {"timeout": 20}, + {}, + pytest.param({"timeout": -1}, marks=pytest.mark.xfail), + pytest.param({"timeout": None}, marks=pytest.mark.xfail), + ], + indirect=True, + ) + def test_set_timeout(self, spark_config_helper): + timeout = spark_config_helper.set_timeout() + assert timeout == spark_config_helper.config.get("timeout", 7200) + + @pytest.mark.parametrize( + "spark_config", + [ + {"polling_interval": 5}, + {"polling_interval": 10}, + {"polling_interval": 20}, + {}, + pytest.param({"polling_interval": -1}, marks=pytest.mark.xfail), + ], + indirect=True, + ) + def test_set_polling_interval(self, spark_config_helper): + polling_interval = spark_config_helper.set_polling_interval() + assert polling_interval == spark_config_helper.config.get("polling_interval", 5) + + @pytest.mark.parametrize( + "spark_config", + [ + {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}}, + {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 2}}, + {}, + pytest.param({"engine_config": {"CoordinatorDpuSize": 1}}, marks=pytest.mark.xfail), + pytest.param({"engine_config": [1, 1, 1]}, marks=pytest.mark.xfail), + ], + indirect=True, + ) + def test_set_engine_config(self, spark_config_helper): + engine_config = spark_config_helper.set_engine_config() + assert engine_config == spark_config_helper.config.get( + "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} + ) diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 37675c8e..73189943 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,457 +1,14 @@ import time -import uuid from unittest.mock import Mock, patch import pytest -from dbt.adapters.athena import python_submissions -from dbt.adapters.athena.python_submissions import ( - AthenaPythonJobHelper, - AthenaSparkSessionConfig, - AthenaSparkSessionManager, -) -from dbt.exceptions import DbtRuntimeError +from dbt.adapters.athena.python_submissions import AthenaPythonJobHelper +from dbt.adapters.athena.session import AthenaSparkSessionManager from .constants import DATABASE_NAME -class TestAthenaSparkSessionConfig: - """ - A class to test AthenaSparkSessionConfig - """ - - @pytest.fixture - def spark_config(self, request): - """ - Fixture for providing Spark configuration parameters. - - This fixture returns a dictionary containing the Spark configuration parameters. The parameters can be - customized using the `request.param` object. The default values are: - - `timeout`: 7200 seconds - - `polling_interval`: 5 seconds - - `engine_config`: {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} - - Args: - self: The test class instance. - request: The pytest request object. - - Returns: - dict: The Spark configuration parameters. - - """ - return { - "timeout": request.param.get("timeout", 7200), - "polling_interval": request.param.get("polling_interval", 5), - "engine_config": request.param.get( - "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} - ), - } - - @pytest.fixture - def spark_config_helper(self, spark_config): - """Fixture for testing AthenaSparkSessionConfig class. - - Args: - spark_config (dict): Fixture for default spark config. - - Returns: - AthenaSparkSessionConfig: An instance of AthenaSparkSessionConfig class. - """ - return AthenaSparkSessionConfig(spark_config) - - @pytest.mark.parametrize( - "spark_config", - [ - {"timeout": 5}, - {"timeout": 10}, - {"timeout": 20}, - {}, - pytest.param({"timeout": -1}, marks=pytest.mark.xfail), - pytest.param({"timeout": None}, marks=pytest.mark.xfail), - ], - indirect=True, - ) - def test_set_timeout(self, spark_config_helper): - timeout = spark_config_helper.set_timeout() - assert timeout == spark_config_helper.config.get("timeout", 7200) - - @pytest.mark.parametrize( - "spark_config", - [ - {"polling_interval": 5}, - {"polling_interval": 10}, - {"polling_interval": 20}, - {}, - pytest.param({"polling_interval": -1}, marks=pytest.mark.xfail), - ], - indirect=True, - ) - def test_set_polling_interval(self, spark_config_helper): - polling_interval = spark_config_helper.set_polling_interval() - assert polling_interval == spark_config_helper.config.get("polling_interval", 5) - - @pytest.mark.parametrize( - "spark_config", - [ - {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}}, - {"engine_config": {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 2}}, - {}, - pytest.param({"engine_config": {"CoordinatorDpuSize": 1}}, marks=pytest.mark.xfail), - pytest.param({"engine_config": [1, 1, 1]}, marks=pytest.mark.xfail), - ], - indirect=True, - ) - def test_set_engine_config(self, spark_config_helper): - engine_config = spark_config_helper.set_engine_config() - assert engine_config == spark_config_helper.config.get( - "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} - ) - - -@pytest.mark.usefixtures("athena_credentials", "athena_client") -class TestAthenaSparkSessionManager: - """ - A class to test the AthenaSparkSessionManager - """ - - @pytest.fixture - def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): - """ - Fixture for creating a mock Spark session manager. - - This fixture creates an instance of AthenaSparkSessionManager with the provided Athena credentials, - timeout, polling interval, and engine configuration. It then patches the Athena client of the manager - with the provided `athena_client` object. The fixture returns the mock Spark session manager. - - Args: - self: The test class instance. - athena_credentials: The Athena credentials. - athena_client: The Athena client object. - monkeypatch: The monkeypatch object for mocking. - - Returns: - The mock Spark session manager. - - """ - mock_session_manager = AthenaSparkSessionManager( - athena_credentials, - timeout=10, - polling_interval=5, - engine_config={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, - ) - monkeypatch.setattr(mock_session_manager, "athena_client", athena_client) - return mock_session_manager - - @pytest.mark.parametrize( - "session_status_response, expected_response", - [ - pytest.param( - { - "Sessions": [ - { - "SessionId": "string", - "Status": { - "StartDateTime": "number", - "State": "IDLE", - }, - } - ], - }, - [ - { - "SessionId": "string", - "Status": { - "StartDateTime": "number", - "State": "IDLE", - }, - } - ], - ), - ( - { - "Sessions": [], - }, - {}, - ), - ], - ) - def test_list_sessions( - self, session_status_response, expected_response, spark_session_manager, athena_client - ) -> None: - """ - Test the _list_sessions method of the AthenaJobHelper class. - - Args: - session_status_response (dict): The response object to be returned by the mock Athena client. - expected_response (dict): The expected output of the _list_sessions method. - athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. - athena_client (Mock): A mock instance of the Athena client. - - Returns: - None: This function only asserts the output of the _list_sessions method. - - Raises: - AssertionError: If the output of the _list_sessions method does not match the expected output. - """ - with patch.object(athena_client, "list_sessions", return_value=session_status_response): - response = spark_session_manager.list_sessions() - assert response == expected_response - - @pytest.mark.parametrize( - "session_status_response, expected_response", - [ - pytest.param( - {"Status": {"SessionId": "test_session_id", "State": "CREATING"}}, - DbtRuntimeError( - """Session - did not create within 10 seconds.""" - ), - marks=pytest.mark.xfail, - ), - ( - {"Status": {"SessionId": "test_session_id", "State": "IDLE"}}, - "test_session_id", - ), - pytest.param( - {"Status": {"SessionId": "test_session_id", "State": "TERMINATED"}}, - DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."), - marks=pytest.mark.xfail, - ), - ], - ) - def test_start_session( - self, session_status_response, expected_response, spark_session_manager, athena_client - ) -> None: - """ - Test the start_session method of the AthenaJobHelper class. - - Args: - session_status_response (dict): A dictionary containing the response from the Athena session - creation status. - expected_response (Union[dict, DbtRuntimeError]): The expected response from the start_session method. - athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. - athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. - - Returns: - None - """ - with patch.multiple( - spark_session_manager, - poll_until_session_creation=Mock(return_value=session_status_response), - ), patch.multiple( - athena_client, - get_session_status=Mock(return_value=session_status_response), - start_session=Mock(return_value=session_status_response.get("Status")), - ): - response = spark_session_manager.start_session() - assert response == expected_response - - @pytest.mark.parametrize( - "session_status_response, expected_status", - [ - ( - { - "SessionId": "test_session_id", - "Status": { - "State": "CREATING", - }, - }, - { - "State": "CREATING", - }, - ), - ( - { - "SessionId": "test_session_id", - "Status": { - "State": "IDLE", - }, - }, - { - "State": "IDLE", - }, - ), - ], - ) - def test_get_session_status(self, session_status_response, expected_status, spark_session_manager, athena_client): - """ - Test the get_session_status function. - - Args: - self: The test class instance. - session_status_response (dict): The response from get_session_status. - expected_status (dict): The expected session status. - spark_session_manager: The Spark session manager object. - athena_client: The Athena client object. - - Returns: - None - - Raises: - AssertionError: If the retrieved session status is not correct. - """ - with patch.multiple(athena_client, get_session_status=Mock(return_value=session_status_response)): - response = spark_session_manager.get_session_status("test_session_id") - assert response == expected_status - - @pytest.mark.parametrize( - "list_sessions_response, session_locks", - [ - ( - [ - { - "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", - "Status": { - "State": "string", - }, - }, - { - "SessionId": "39cb8fc0-f855-4b67-91f1-81f068499071", - "Status": { - "State": "string", - }, - }, - ], - {"test_session_id": None}, - ), - ( - [], - {}, - ), - ( - [ - { - "SessionId": "106d7aca-4b3f-468d-a81d-308120e7f73c", - "Status": { - "State": "string", - }, - }, - ], - {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): "lock"}, - ), - ], - ) - def test_get_sessions( - self, list_sessions_response, session_locks, spark_session_manager, athena_client, monkeypatch - ): - """ - Test the get_sessions function. - - Args: - self: The test class instance. - list_sessions_response (list): The response from list_sessions. - session_locks (dict): The session locks. - spark_session_manager: The Spark session manager object. - athena_client: The Athena client object. - monkeypatch: The monkeypatch object for mocking. - - Returns: - None - - Raises: - AssertionError: If the retrieved sessions are not correct. - """ - monkeypatch.setattr(python_submissions, "session_locks", session_locks) - with patch.multiple( - spark_session_manager, - list_sessions=Mock(return_value=list_sessions_response), - ): - sessions = spark_session_manager.get_sessions() - assert sessions == [uuid.UUID(response["SessionId"]) for response in list_sessions_response] - - @pytest.mark.parametrize( - "get_session_response, current_session_locks", - [([], {}), ([uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], {})], - ) - def test_update_session_locks( - self, get_session_response, current_session_locks, spark_session_manager, monkeypatch - ): - """ - Test the update_session_locks function. - - Args: - self: The test class instance. - get_session_response (list): The response from get_sessions. - current_session_locks (dict): The current session locks. - spark_session_manager: The Spark session manager object. - monkeypatch: The monkeypatch object for mocking. - - Raises: - AssertionError: If the session locks are not updated correctly. - """ - monkeypatch.setattr(python_submissions, "session_locks", current_session_locks) - with patch.multiple( - spark_session_manager, - get_sessions=Mock(return_value=get_session_response), - ): - spark_session_manager.update_session_locks() - for session in get_session_response: - assert session in python_submissions.session_locks.keys() - assert type(python_submissions.session_locks[session]) is not None - - def test_get_session_id(self): - pass - - @pytest.mark.parametrize( - "test_session_id, get_session_status_response, current_session_locks, terminate_session_response", - [ - ( - "106d7aca-4b3f-468d-a81d-308120e7f73c", - { - "State": "string", - }, - {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, - {"State": "TERMINATED"}, - ), - ( - "106d7aca-4b3f-468d-a81d-308120e7f73c", - { - "State": "string", - }, - {uuid.UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, - {"State": "CREATED"}, - ), - ], - ) - def test_release_session_lock( - self, - test_session_id, - get_session_status_response, - current_session_locks, - terminate_session_response, - spark_session_manager, - athena_client, - monkeypatch, - ): - """ - Test the release_session_lock function. - - Args: - self: The test class instance. - test_session_id (str): The ID of the test session. - get_session_status_response (dict): The response from get_session_status. - current_session_locks (dict): The current session locks. - terminate_session_response (dict): The response from terminate_session. - spark_session_manager: The Spark session manager object. - athena_client: The Athena client object. - monkeypatch: The monkeypatch object for mocking. - - Raises: - AssertionError: If the session lock is not released correctly. - """ - monkeypatch.setattr(python_submissions, "session_locks", current_session_locks) - with patch.multiple( - spark_session_manager, - get_session_status=Mock(return_value=get_session_status_response), - ), patch.multiple( - athena_client, - terminate_session=Mock(return_value=terminate_session_response), - ): - spark_session_manager.release_session_lock(test_session_id) - assert uuid.UUID(test_session_id) in python_submissions.session_locks.keys() - assert type(python_submissions.session_locks[uuid.UUID(test_session_id)]) is not None - - @pytest.mark.usefixtures("athena_credentials", "athena_client") class TestAthenaPythonJobHelper: """ @@ -498,14 +55,14 @@ def athena_job_helper( { "State": "IDLE", }, - "IDLE", + None, ), pytest.param( {"config": {"timeout": 5, "polling_interval": 5}}, { "State": "FAILED", }, - "FAILED", + None, marks=pytest.mark.xfail, ), pytest.param( @@ -513,7 +70,7 @@ def athena_job_helper( { "State": "TERMINATED", }, - "TERMINATED", + None, marks=pytest.mark.xfail, ), pytest.param( @@ -521,7 +78,7 @@ def athena_job_helper( { "State": "CREATING", }, - "CREATING", + None, marks=pytest.mark.xfail, ), ], @@ -555,14 +112,15 @@ def mock_sleep(_): }, "COMPLETED", ), - ( + pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, { "Status": { "State": "FAILED", } }, - "FAILED", + None, + marks=pytest.mark.xfail, ), pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, @@ -613,13 +171,14 @@ def mock_sleep(_): pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, {"CalculationExecutionId": "test_execution_id"}, - {"Result": {"ResultS3Uri": None}}, + {"Result": {}}, {"Status": {"State": "FAILED"}}, + marks=pytest.mark.xfail, ), pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, {}, - {"Result": {"ResultS3Uri": None}}, + {"Result": {}}, {"Status": {"State": "FAILED"}}, marks=pytest.mark.xfail, ), @@ -648,4 +207,4 @@ def test_submission( athena_job_helper, poll_until_session_idle=Mock(return_value="IDLE") ): result = athena_job_helper.submit("hello world") - assert result == test_calculation_execution["Result"]["ResultS3Uri"] + assert result == test_calculation_execution["Result"] diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 7e9c4e43..cd4681fb 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1,9 +1,13 @@ +from unittest.mock import Mock, patch +from uuid import UUID + import botocore.session import pytest -from dbt.adapters.athena import AthenaCredentials -from dbt.adapters.athena.session import get_boto3_session +from dbt.adapters.athena import AthenaCredentials, session +from dbt.adapters.athena.session import AthenaSparkSessionManager, get_boto3_session from dbt.contracts.connection import Connection +from dbt.exceptions import DbtRuntimeError class TestSession: @@ -35,3 +39,336 @@ def mock___build_profile_map(_): session = get_boto3_session(connection) assert session.region_name == "eu-west-1" assert session.profile_name == boto_profile_name + + +@pytest.mark.usefixtures("athena_credentials", "athena_client") +class TestAthenaSparkSessionManager: + """ + A class to test the AthenaSparkSessionManager + """ + + @pytest.fixture + def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): + """ + Fixture for creating a mock Spark session manager. + + This fixture creates an instance of AthenaSparkSessionManager with the provided Athena credentials, + timeout, polling interval, and engine configuration. It then patches the Athena client of the manager + with the provided `athena_client` object. The fixture returns the mock Spark session manager. + + Args: + self: The test class instance. + athena_credentials: The Athena credentials. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Returns: + The mock Spark session manager. + + """ + mock_session_manager = AthenaSparkSessionManager( + athena_credentials, + timeout=10, + polling_interval=5, + engine_config={"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1}, + ) + monkeypatch.setattr(mock_session_manager, "athena_client", athena_client) + return mock_session_manager + + @pytest.mark.parametrize( + "session_status_response, expected_response", + [ + pytest.param( + { + "Sessions": [ + { + "SessionId": "635c1c6d-766c-408b-8bce-fae8ea7006f7", + "Status": { + "StartDateTime": "number", + "State": "IDLE", + }, + } + ], + }, + [UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7")], + ), + ( + { + "Sessions": [], + }, + [UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7")], + ), + ], + ) + def test_list_sessions( + self, session_status_response, expected_response, spark_session_manager, athena_client + ) -> None: + """ + Test the _list_sessions method of the AthenaJobHelper class. + + Args: + session_status_response (dict): The response object to be returned by the mock Athena client. + expected_response (dict): The expected output of the _list_sessions method. + athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. + athena_client (Mock): A mock instance of the Athena client. + + Returns: + None: This function only asserts the output of the _list_sessions method. + + Raises: + AssertionError: If the output of the _list_sessions method does not match the expected output. + """ + with patch.object(athena_client, "list_sessions", return_value=session_status_response), patch.object( + AthenaSparkSessionManager, "start_session", return_value=UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7") + ): + response = spark_session_manager.list_sessions() + assert response == expected_response + + @pytest.mark.parametrize( + "session_status_response, expected_response", + [ + pytest.param( + {"Status": {"SessionId": "test_session_id", "State": "CREATING"}}, + DbtRuntimeError( + """Session + did not create within 10 seconds.""" + ), + marks=pytest.mark.xfail, + ), + ( + {"Status": {"SessionId": "635c1c6d-766c-408b-8bce-fae8ea7006f7", "State": "IDLE"}}, + UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7"), + ), + pytest.param( + {"Status": {"SessionId": "test_session_id", "State": "TERMINATED"}}, + DbtRuntimeError("Unable to create session: test_session_id. Got status: TERMINATED."), + marks=pytest.mark.xfail, + ), + ], + ) + def test_start_session( + self, session_status_response, expected_response, spark_session_manager, athena_client + ) -> None: + """ + Test the start_session method of the AthenaJobHelper class. + + Args: + session_status_response (dict): A dictionary containing the response from the Athena session + creation status. + expected_response (Union[dict, DbtRuntimeError]): The expected response from the start_session method. + athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. + athena_client (botocore.client.BaseClient): An instance of the botocore Athena client. + + Returns: + None + """ + with patch.multiple( + spark_session_manager, + poll_until_session_creation=Mock(return_value=session_status_response), + ), patch.multiple( + athena_client, + get_session_status=Mock(return_value=session_status_response), + start_session=Mock(return_value=session_status_response.get("Status")), + ): + response = spark_session_manager.start_session() + assert response == expected_response + + @pytest.mark.parametrize( + "session_status_response, expected_status", + [ + ( + { + "SessionId": "test_session_id", + "Status": { + "State": "CREATING", + }, + }, + { + "State": "CREATING", + }, + ), + ( + { + "SessionId": "test_session_id", + "Status": { + "State": "IDLE", + }, + }, + { + "State": "IDLE", + }, + ), + ], + ) + def test_get_session_status(self, session_status_response, expected_status, spark_session_manager, athena_client): + """ + Test the get_session_status function. + + Args: + self: The test class instance. + session_status_response (dict): The response from get_session_status. + expected_status (dict): The expected session status. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + + Returns: + None + + Raises: + AssertionError: If the retrieved session status is not correct. + """ + with patch.multiple(athena_client, get_session_status=Mock(return_value=session_status_response)): + response = spark_session_manager.get_session_status("test_session_id") + assert response == expected_status + + @pytest.mark.parametrize( + "list_sessions_response, spark_session_locks, expected_new_sessions", + [ + ( + [ + UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"), + UUID("39cb8fc0-f855-4b67-91f1-81f068499071"), + ], + {"test_session_id": None}, + [ + UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"), + UUID("39cb8fc0-f855-4b67-91f1-81f068499071"), + ], + ), + ( + [], + {}, + [], + ), + ( + [UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], + {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): "lock"}, + [], + ), + ], + ) + def test_get_sessions( + self, + list_sessions_response, + spark_session_locks, + expected_new_sessions, + spark_session_manager, + athena_client, + monkeypatch, + ): + """ + Test the get_sessions function. + + Args: + self: The test class instance. + list_sessions_response (list): The response from list_sessions. + spark_session_locks (dict): The session locks. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Returns: + None + + Raises: + AssertionError: If the retrieved sessions are not correct. + """ + monkeypatch.setattr(session, "spark_session_locks", spark_session_locks) + with patch.multiple( + spark_session_manager, + list_sessions=Mock(return_value=list_sessions_response), + ): + sessions = spark_session_manager.get_new_sessions() + assert sessions == expected_new_sessions + + @pytest.mark.parametrize( + "get_session_response, current_spark_session_locks", + [([], {}), ([UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], {})], + ) + def test_update_spark_session_locks( + self, get_session_response, current_spark_session_locks, spark_session_manager, monkeypatch + ): + """ + Test the update_spark_session_locks function. + + Args: + self: The test class instance. + get_session_response (list): The response from get_sessions. + current_spark_session_locks (dict): The current session locks. + spark_session_manager: The Spark session manager object. + monkeypatch: The monkeypatch object for mocking. + + Raises: + AssertionError: If the session locks are not updated correctly. + """ + monkeypatch.setattr(session, "spark_session_locks", current_spark_session_locks) + with patch.multiple( + spark_session_manager, + get_new_sessions=Mock(return_value=get_session_response), + ): + spark_session_manager.update_spark_session_locks() + for each_session in get_session_response: + assert each_session in session.spark_session_locks.keys() + assert type(session.spark_session_locks[each_session]) is not None + + def test_get_session_id(self): + pass + + @pytest.mark.parametrize( + "test_session_id, get_session_status_response, current_spark_session_locks, terminate_session_response", + [ + ( + "106d7aca-4b3f-468d-a81d-308120e7f73c", + { + "State": "string", + }, + {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, + {"State": "TERMINATED"}, + ), + ( + "106d7aca-4b3f-468d-a81d-308120e7f73c", + { + "State": "string", + }, + {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, + {"State": "CREATED"}, + ), + ], + ) + def test_release_session_lock( + self, + test_session_id, + get_session_status_response, + current_spark_session_locks, + terminate_session_response, + spark_session_manager, + athena_client, + monkeypatch, + ): + """ + Test the release_session_lock function. + + Args: + self: The test class instance. + test_session_id (str): The ID of the test session. + get_session_status_response (dict): The response from get_session_status. + current_spark_session_locks (dict): The current session locks. + terminate_session_response (dict): The response from terminate_session. + spark_session_manager: The Spark session manager object. + athena_client: The Athena client object. + monkeypatch: The monkeypatch object for mocking. + + Raises: + AssertionError: If the session lock is not released correctly. + """ + monkeypatch.setattr(session, "spark_session_locks", current_spark_session_locks) + with patch.multiple( + spark_session_manager, + get_session_status=Mock(return_value=get_session_status_response), + ), patch.multiple( + athena_client, + terminate_session=Mock(return_value=terminate_session_response), + ): + spark_session_manager.release_session_lock(test_session_id) + assert UUID(test_session_id) in session.spark_session_locks.keys() + assert type(session.spark_session_locks[UUID(test_session_id)]) is not None From 76c5c88da42058a598d7bb971a5e249fd749944f Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Sat, 10 Jun 2023 00:18:46 -0300 Subject: [PATCH 49/75] Extracted session_count and spark work group methods --- dbt/adapters/athena/constants.py | 3 +++ dbt/adapters/athena/session.py | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/dbt/adapters/athena/constants.py b/dbt/adapters/athena/constants.py index bfd6e4b0..df6bd86b 100644 --- a/dbt/adapters/athena/constants.py +++ b/dbt/adapters/athena/constants.py @@ -1,3 +1,6 @@ from dbt.events import AdapterLogger +DEFAULT_THREAD_COUNT = 4 +DEFAULT_RETRY_ATTEMPTS = 3 + LOGGER = AdapterLogger(__name__) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 138e0d61..2068fc42 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -7,11 +7,10 @@ import boto3 import boto3.session -from dbt.adapters.athena.constants import LOGGER +from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER from dbt.contracts.connection import Connection from dbt.exceptions import DbtRuntimeError -DEFAULT_SESSION_COUNT = 4 spark_session_locks: Dict[UUID, threading.Lock] = {} @@ -37,6 +36,16 @@ def __init__(self, credentials, timeout: int, polling_interval: float, engine_co self.lock = threading.Lock() self.athena_client = self.get_athena_client() + def get_max_session_count(self): + if not self.credentials.threads: + return DEFAULT_THREAD_COUNT + return self.credentials.threads + + def get_spark_work_group(self): + if not self.credentials.spark_work_group: + raise DbtRuntimeError("Expected spark_work_group in profile") + return self.credentials.spark_work_group + def get_athena_client(self): """ Get the AWS Athena client. @@ -110,7 +119,7 @@ def get_session_id(self) -> UUID: time.sleep(polling_interval) polling_interval *= 2 - def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = "IDLE") -> List[UUID]: + def list_sessions(self, state: str = "IDLE") -> List[UUID]: """ List athena spark sessions. @@ -123,14 +132,17 @@ def list_sessions(self, max_results: int = DEFAULT_SESSION_COUNT, state: str = " the list of the returned session id. """ + max_results = self.get_max_session_count() response = self.athena_client.list_sessions( - WorkGroup=self.credentials.spark_work_group, MaxResults=max_results, StateFilter=state + WorkGroup=self.get_spark_work_group(), + MaxResults=max_results, + StateFilter=state, ) if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: - if len(spark_session_locks) < DEFAULT_SESSION_COUNT: + if len(spark_session_locks) < max_results: return [self.start_session()] LOGGER.warning( - f"""Maximum spark session count: {DEFAULT_SESSION_COUNT} reached. + f"""Maximum spark session count: {max_results} reached. Cannot start new spark session.""" ) return [UUID(session_string["SessionId"]) for session_string in response.get("Sessions")] From 1fd436ef65547f6b8218c7ef1581e8f0efb9db51 Mon Sep 17 00:00:00 2001 From: Julian Steger <108534789+juliansteger-sc@users.noreply.github.com> Date: Fri, 9 Jun 2023 13:40:28 +0200 Subject: [PATCH 50/75] fix: BatchDeletePartitions only accepts up to 25 partitions (#328) --- dbt/adapters/athena/impl.py | 37 ++++++++++++++++++++++-------------- dbt/adapters/athena/utils.py | 11 ++++++++++- tests/unit/test_adapter.py | 4 ++-- tests/unit/test_utils.py | 19 +++++++++++++++++- tests/unit/utils.py | 2 +- 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index 48f982b1..2c4e47e6 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -35,7 +35,7 @@ get_table_type, ) from dbt.adapters.athena.s3 import S3DataNaming -from dbt.adapters.athena.utils import clean_sql_comment, get_catalog_id +from dbt.adapters.athena.utils import clean_sql_comment, get_catalog_id, get_chunks from dbt.adapters.base import ConstraintSupport, PythonJobHelper, available from dbt.adapters.base.relation import BaseRelation, InformationSchema from dbt.adapters.sql import SQLAdapter @@ -48,6 +48,9 @@ class AthenaAdapter(SQLAdapter): + BATCH_CREATE_PARTITION_API_LIMIT = 100 + BATCH_DELETE_PARTITION_API_LIMIT = 25 + ConnectionManager = AthenaConnectionManager Relation = AthenaRelation @@ -524,21 +527,27 @@ def swap_table(self, src_relation: AthenaRelation, target_relation: AthenaRelati # if source table has partitions we need to delete and add partitions # it source table hasn't any partitions we need to delete target table partitions if target_table_partitions: - glue_client.batch_delete_partition( - DatabaseName=target_relation.schema, - TableName=target_relation.identifier, - PartitionsToDelete=[{"Values": i["Values"]} for i in target_table_partitions], - ) + for partition_batch in get_chunks(target_table_partitions, AthenaAdapter.BATCH_DELETE_PARTITION_API_LIMIT): + glue_client.batch_delete_partition( + DatabaseName=target_relation.schema, + TableName=target_relation.identifier, + PartitionsToDelete=[{"Values": partition["Values"]} for partition in partition_batch], + ) if src_table_partitions: - glue_client.batch_create_partition( - DatabaseName=target_relation.schema, - TableName=target_relation.identifier, - PartitionInputList=[ - {"Values": p["Values"], "StorageDescriptor": p["StorageDescriptor"], "Parameters": p["Parameters"]} - for p in src_table_partitions - ], - ) + for partition_batch in get_chunks(src_table_partitions, AthenaAdapter.BATCH_CREATE_PARTITION_API_LIMIT): + glue_client.batch_create_partition( + DatabaseName=target_relation.schema, + TableName=target_relation.identifier, + PartitionInputList=[ + { + "Values": partition["Values"], + "StorageDescriptor": partition["StorageDescriptor"], + "Parameters": partition["Parameters"], + } + for partition in partition_batch + ], + ) def _get_glue_table_versions_to_expire(self, relation: AthenaRelation, to_keep: int): """ diff --git a/dbt/adapters/athena/utils.py b/dbt/adapters/athena/utils.py index b0e9bbdf..ee3b20bc 100644 --- a/dbt/adapters/athena/utils.py +++ b/dbt/adapters/athena/utils.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Collection, List, Optional, TypeVar from mypy_boto3_athena.type_defs import DataCatalogTypeDef @@ -11,3 +11,12 @@ def clean_sql_comment(comment: str) -> str: def get_catalog_id(catalog: Optional[DataCatalogTypeDef]) -> Optional[str]: if catalog: return catalog["Parameters"]["catalog-id"] + + +T = TypeVar("T") + + +def get_chunks(lst: Collection[T], n: int) -> Collection[List[T]]: + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] diff --git a/tests/unit/test_adapter.py b/tests/unit/test_adapter.py index 55397f10..ad66d536 100644 --- a/tests/unit/test_adapter.py +++ b/tests/unit/test_adapter.py @@ -728,7 +728,7 @@ def test_swap_table_with_partitions(self, mock_aws_service): mock_aws_service.create_table(source_table) mock_aws_service.add_partitions_to_table(DATABASE_NAME, source_table) mock_aws_service.create_table(target_table) - mock_aws_service.add_partitions_to_table(DATABASE_NAME, source_table) + mock_aws_service.add_partitions_to_table(DATABASE_NAME, target_table) source_relation = self.adapter.Relation.create( database=DATA_CATALOG_NAME, schema=DATABASE_NAME, @@ -836,7 +836,7 @@ def test_swap_table_with_no_partitions_to_one_with(self, mock_aws_service): ).get("Partitions") assert self.adapter.get_glue_table_location(target_relation) == f"s3://{BUCKET}/tables/{source_table}" - assert len(target_table_partitions_after) == 3 + assert len(target_table_partitions_after) == 26 @mock_athena @mock_glue diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index b72d9311..23851360 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,4 +1,4 @@ -from dbt.adapters.athena.utils import clean_sql_comment +from dbt.adapters.athena.utils import clean_sql_comment, get_chunks def test_clean_comment(): @@ -12,3 +12,20 @@ def test_clean_comment(): ) == "my long comment on several lines with weird spaces and indents." ) + + +def test_get_chunks_empty(): + assert len(list(get_chunks([], 5))) == 0 + + +def test_get_chunks_uneven(): + chunks = list(get_chunks([1, 2, 3], 2)) + assert chunks[0] == [1, 2] + assert chunks[1] == [3] + assert len(chunks) == 2 + + +def test_get_chunks_more_elements_than_chunk(): + chunks = list(get_chunks([1, 2, 3], 4)) + assert chunks[0] == [1, 2, 3] + assert len(chunks) == 1 diff --git a/tests/unit/utils.py b/tests/unit/utils.py index eeacd230..60ec02ed 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -450,7 +450,7 @@ def add_partitions_to_table(self, database, table_name): }, "Parameters": {"compressionType": "snappy", "classification": "parquet"}, } - for dt in ["2022-01-01", "2022-01-02", "2022-01-03"] + for dt in [f"2022-01-{day:02d}" for day in range(1, 27)] ] glue = boto3.client("glue", region_name=AWS_REGION) glue.batch_create_partition( From 0bdaa7ed9b765c549f75921f4ce0e84152a7611f Mon Sep 17 00:00:00 2001 From: Serhii Dimchenko <39801237+svdimchenko@users.noreply.github.com> Date: Fri, 9 Jun 2023 14:00:25 +0200 Subject: [PATCH 51/75] feat: enable mypy pre-commit check (#329) --- .pre-commit-config.yaml | 13 ++++++ README.md | 1 + dbt/adapters/athena/column.py | 10 ++--- dbt/adapters/athena/connections.py | 28 ++++++------ dbt/adapters/athena/impl.py | 65 ++++++++++++++-------------- dbt/adapters/athena/lakeformation.py | 10 +++-- dbt/adapters/athena/relation.py | 15 +++---- dbt/adapters/athena/utils.py | 7 ++- setup.py | 5 ++- 9 files changed, 85 insertions(+), 69 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0381617f..9e88365e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,3 +59,16 @@ repos: - 'Flake8-pyproject~=1.1' args: - '.' + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.3.0 + hooks: + - id: mypy + args: + - --strict + - --ignore-missing-imports + - --install-types + - --allow-subclassing-any + - --allow-untyped-decorators + additional_dependencies: + - types-setuptools==67.8.0.0 + exclude: ^tests/ diff --git a/README.md b/README.md index 4579c7a1..50a9592b 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ +

    diff --git a/dbt/adapters/athena/column.py b/dbt/adapters/athena/column.py index e5dfdadd..75fc1bdc 100644 --- a/dbt/adapters/athena/column.py +++ b/dbt/adapters/athena/column.py @@ -37,19 +37,17 @@ def timestamp_type(self) -> str: def string_size(self) -> int: if not self.is_string(): raise DbtRuntimeError("Called string_size() on non-string field!") - if not self.char_size: - # Handle error: '>' not supported between instances of 'NoneType' and 'NoneType' for union relations macro - return 0 - return self.char_size + # Handle error: '>' not supported between instances of 'NoneType' and 'NoneType' for union relations macro + return self.char_size or 0 @property def data_type(self) -> str: if self.is_string(): return self.string_type(self.string_size()) elif self.is_numeric(): - return self.numeric_type(self.dtype, self.numeric_precision, self.numeric_scale) + return self.numeric_type(self.dtype, self.numeric_precision, self.numeric_scale) # type: ignore elif self.is_binary(): return self.binary_type() elif self.is_timestamp(): return self.timestamp_type() - return self.dtype + return self.dtype # type: ignore diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index b8965cc6..00c7f42a 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -4,7 +4,7 @@ from copy import deepcopy from dataclasses import dataclass from decimal import Decimal -from typing import Any, ContextManager, Dict, List, Optional, Tuple, Union +from typing import Any, ContextManager, Dict, List, Optional, Tuple import tenacity from pyathena.connection import Connection as AthenaConnection @@ -58,7 +58,7 @@ def type(self) -> str: return "athena" @property - def unique_field(self): + def unique_field(self) -> str: return f"athena-{hashlib.md5(self.s3_staging_dir.encode()).hexdigest()}" def _connection_keys(self) -> Tuple[str, ...]: @@ -81,7 +81,7 @@ def _connection_keys(self) -> Tuple[str, ...]: class AthenaCursor(Cursor): - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: # type: ignore super().__init__(**kwargs) self._executor = ThreadPoolExecutor() @@ -95,7 +95,7 @@ def _collect_result_set(self, query_id: str) -> AthenaResultSet: retry_config=self._retry_config, ) - def execute( + def execute( # type: ignore self, operation: str, parameters: Optional[Dict[str, Any]] = None, @@ -106,7 +106,7 @@ def execute( cache_expiration_time: int = 0, **kwargs, ): - def inner(): + def inner() -> AthenaCursor: query_id = self._execute( operation, parameters=parameters, @@ -146,7 +146,7 @@ class AthenaConnectionManager(SQLConnectionManager): TYPE = "athena" @classmethod - def data_type_code_to_name(cls, type_code: Union[int, str]) -> str: + def data_type_code_to_name(cls, type_code: str) -> str: """ Get the string representation of the data type from the Athena metadata. Dbt performs a query to retrieve the types of the columns in the SQL query. Then these types are compared @@ -155,8 +155,8 @@ def data_type_code_to_name(cls, type_code: Union[int, str]) -> str: """ return type_code.split("(")[0].split("<")[0].upper() - @contextmanager - def exception_handler(self, sql: str) -> ContextManager: + @contextmanager # type: ignore + def exception_handler(self, sql: str) -> ContextManager: # type: ignore try: yield except Exception as e: @@ -204,23 +204,23 @@ def open(cls, connection: Connection) -> Connection: return connection @classmethod - def get_response(cls, cursor) -> AdapterResponse: + def get_response(cls, cursor: AthenaCursor) -> AdapterResponse: code = "OK" if cursor.state == AthenaQueryExecution.STATE_SUCCEEDED else "ERROR" return AdapterResponse(_message=f"{code} {cursor.rowcount}", rows_affected=cursor.rowcount, code=code) - def cancel(self, connection: Connection): + def cancel(self, connection: Connection) -> None: connection.handle.cancel() - def add_begin_query(self): + def add_begin_query(self) -> None: pass - def add_commit_query(self): + def add_commit_query(self) -> None: pass - def begin(self): + def begin(self) -> None: pass - def commit(self): + def commit(self) -> None: pass diff --git a/dbt/adapters/athena/impl.py b/dbt/adapters/athena/impl.py index 2c4e47e6..1c0b3d5f 100755 --- a/dbt/adapters/athena/impl.py +++ b/dbt/adapters/athena/impl.py @@ -12,6 +12,7 @@ import agate from botocore.exceptions import ClientError from mypy_boto3_athena.type_defs import DataCatalogTypeDef +from mypy_boto3_glue.type_defs import ColumnTypeDef, TableTypeDef, TableVersionTypeDef from dbt.adapters.athena import AthenaConnectionManager from dbt.adapters.athena.column import AthenaColumn @@ -103,7 +104,7 @@ def apply_lf_grants(self, relation: AthenaRelation, lf_grants_config: Dict[str, lf = client.session.client("lakeformation", region_name=client.region_name, config=get_boto3_config()) catalog = self._get_data_catalog(relation.database) catalog_id = get_catalog_id(catalog) - lf_permissions = LfPermissions(catalog_id, relation, lf) + lf_permissions = LfPermissions(catalog_id, relation, lf) # type: ignore lf_permissions.process_filters(lf_config) lf_permissions.process_permissions(lf_config) @@ -148,7 +149,7 @@ def _s3_table_prefix(self, s3_data_dir: Optional[str]) -> str: return path.join(creds.s3_staging_dir, "tables") - def _s3_data_naming(self, s3_data_naming: Optional[str]) -> Optional[S3DataNaming]: + def _s3_data_naming(self, s3_data_naming: Optional[str]) -> S3DataNaming: """ Returns the s3 data naming strategy if provided, otherwise the value from the connection. """ @@ -177,7 +178,6 @@ def generate_s3_location( s3_path_table_part = relation.s3_path_table_part or relation.identifier schema_name = relation.schema - s3_data_naming = self._s3_data_naming(s3_data_naming) table_prefix = self._s3_table_prefix(s3_data_dir) mapping = { @@ -188,7 +188,7 @@ def generate_s3_location( S3DataNaming.SCHEMA_TABLE_UNIQUE: path.join(table_prefix, schema_name, s3_path_table_part, str(uuid4())), } - return mapping[s3_data_naming] + return mapping[self._s3_data_naming(s3_data_naming)] @available def get_glue_table_location(self, relation: AthenaRelation) -> Optional[str]: @@ -218,12 +218,11 @@ def get_glue_table_location(self, relation: AthenaRelation) -> Optional[str]: f"but no location returned by Glue." ) LOGGER.debug(f"{relation.render()} is stored in {table_location}") - return table_location - + return str(table_location) return None @available - def clean_up_partitions(self, relation: AthenaRelation, where_condition: str): + def clean_up_partitions(self, relation: AthenaRelation, where_condition: str) -> None: conn = self.connections.get_thread_connection() client = conn.handle @@ -242,17 +241,17 @@ def clean_up_partitions(self, relation: AthenaRelation, where_condition: str): self.delete_from_s3(partition["StorageDescriptor"]["Location"]) @available - def clean_up_table(self, relation: AthenaRelation): + def clean_up_table(self, relation: AthenaRelation) -> None: table_location = self.get_glue_table_location(relation) - # this check avoid issues for when the table location is an empty string - # or when the table do not exist and table location is None + # this check avoids issues for when the table location is an empty string + # or when the table does not exist and table location is None if table_location: self.delete_from_s3(table_location) @available def quote_seed_column(self, column: str, quote_config: Optional[bool]) -> str: - return super().quote_seed_column(column, False) + return str(super().quote_seed_column(column, False)) @available def upload_seed_to_s3( @@ -283,10 +282,10 @@ def upload_seed_to_s3( s3_client.upload_file(tmpfile, bucket, object_name) os.remove(tmpfile) - return s3_location + return str(s3_location) @available - def delete_from_s3(self, s3_path: str): + def delete_from_s3(self, s3_path: str) -> None: """ Deletes files from s3 given a s3 path in the format: s3://my_bucket/prefix Additionally, parses the response from the s3 delete request and raises @@ -361,7 +360,7 @@ def _join_catalog_table_owners(self, table: agate.Table, manifest: Manifest) -> right_key=join_keys, ) - def _get_one_table_for_catalog(self, table: dict, database: str) -> list: + def _get_one_table_for_catalog(self, table: TableTypeDef, database: str) -> List[Dict[str, Any]]: table_catalog = { "table_database": database, "table_schema": table["DatabaseName"], @@ -408,7 +407,7 @@ def _get_one_catalog( for page in paginator.paginate(**kwargs): for table in page["TableList"]: - if table["Name"] in relations: + if relations and table["Name"] in relations: catalog.extend(self._get_one_table_for_catalog(table, information_schema.path.database)) table = agate.Table.from_object(catalog) @@ -435,17 +434,17 @@ def _get_data_catalog(self, database: str) -> Optional[DataCatalogTypeDef]: sts = client.session.client("sts", region_name=client.region_name, config=get_boto3_config()) catalog_id = sts.get_caller_identity()["Account"] return {"Name": database, "Type": "GLUE", "Parameters": {"catalog-id": catalog_id}} - else: - with boto3_client_lock: - athena = client.session.client("athena", region_name=client.region_name, config=get_boto3_config()) - return athena.get_data_catalog(Name=database)["DataCatalog"] + with boto3_client_lock: + athena = client.session.client("athena", region_name=client.region_name, config=get_boto3_config()) + return athena.get_data_catalog(Name=database)["DataCatalog"] + return None def list_relations_without_caching(self, schema_relation: AthenaRelation) -> List[BaseRelation]: data_catalog = self._get_data_catalog(schema_relation.database) catalog_id = get_catalog_id(data_catalog) if data_catalog and data_catalog["Type"] != "GLUE": # For non-Glue Data Catalogs, use the original Athena query against INFORMATION_SCHEMA approach - return super().list_relations_without_caching(schema_relation) + return super().list_relations_without_caching(schema_relation) # type: ignore conn = self.connections.get_thread_connection() client = conn.handle @@ -494,7 +493,7 @@ def list_relations_without_caching(self, schema_relation: AthenaRelation) -> Lis return relations @available - def swap_table(self, src_relation: AthenaRelation, target_relation: AthenaRelation): + def swap_table(self, src_relation: AthenaRelation, target_relation: AthenaRelation) -> None: conn = self.connections.get_thread_connection() client = conn.handle @@ -549,7 +548,7 @@ def swap_table(self, src_relation: AthenaRelation, target_relation: AthenaRelati ], ) - def _get_glue_table_versions_to_expire(self, relation: AthenaRelation, to_keep: int): + def _get_glue_table_versions_to_expire(self, relation: AthenaRelation, to_keep: int) -> List[TableVersionTypeDef]: """ Given a table and the amount of its version to keep, it returns the versions to delete """ @@ -572,7 +571,9 @@ def _get_glue_table_versions_to_expire(self, relation: AthenaRelation, to_keep: return table_versions_ordered[int(to_keep) :] @available - def expire_glue_table_versions(self, relation: AthenaRelation, to_keep: int, delete_s3: bool): + def expire_glue_table_versions( + self, relation: AthenaRelation, to_keep: int, delete_s3: bool + ) -> List[TableVersionTypeDef]: conn = self.connections.get_thread_connection() client = conn.handle @@ -608,7 +609,7 @@ def persist_docs_to_glue( model: Dict[str, Any], persist_relation_docs: bool = False, persist_column_docs: bool = False, - ): + ) -> None: conn = self.connections.get_thread_connection() client = conn.handle @@ -666,9 +667,9 @@ def list_schemas(self, database: str) -> List[str]: return result @staticmethod - def _is_current_column(col: dict) -> bool: + def _is_current_column(col: ColumnTypeDef) -> bool: """ - Check if a column is explicit set as not current. If not, it is considered as current. + Check if a column is explicitly set as not current. If not, it is considered as current. """ if col.get("Parameters", {}).get("iceberg.field.current") == "false": return False @@ -704,7 +705,7 @@ def get_columns_in_relation(self, relation: AthenaRelation) -> List[AthenaColumn ] @available - def delete_from_glue_catalog(self, relation: AthenaRelation): + def delete_from_glue_catalog(self, relation: AthenaRelation) -> None: schema_name = relation.schema table_name = relation.identifier @@ -736,16 +737,16 @@ def valid_snapshot_target(self, relation: BaseRelation) -> None: if "dbt_unique_key" in names: sql = self._generate_snapshot_migration_sql(relation=relation, table_columns=table_columns) msg = ( - f"{'!'*90}\n" + f"{'!' * 90}\n" "The snapshot logic of dbt-athena has changed in an incompatible way to be more consistent " "with the dbt-core implementation.\nYou will need to migrate your existing snapshot tables to be " "able to keep using them with the latest dbt-athena version.\nYou can find more information " "in the release notes:\nhttps://github.com/dbt-athena/dbt-athena/releases\n" - f"{'!'*90}\n\n" + f"{'!' * 90}\n\n" "You can use the example query below as a baseline to perform the migration:\n\n" - f"{'-'*90}\n" + f"{'-' * 90}\n" f"{sql}\n" - f"{'-'*90}\n\n" + f"{'-' * 90}\n\n" ) LOGGER.error(msg) raise SnapshotMigrationRequired("Look into 1.5 dbt-athena docs for the complete migration procedure") @@ -760,7 +761,7 @@ def _generate_snapshot_migration_sql(self, relation: AthenaRelation, table_colum - Copy the content of the staging table to the final table - Delete the staging table """ - col_csv = f",\n{' '*16}".join(table_columns) + col_csv = f",\n{' ' * 16}".join(table_columns) staging_relation = relation.incorporate( path={"identifier": relation.identifier + "__dbt_tmp_migration_staging"} ) diff --git a/dbt/adapters/athena/lakeformation.py b/dbt/adapters/athena/lakeformation.py index 043b26d3..cd29e7e6 100644 --- a/dbt/adapters/athena/lakeformation.py +++ b/dbt/adapters/athena/lakeformation.py @@ -74,7 +74,11 @@ def _apply_lf_tags_table( logger.debug(f"EXISTING TABLE TAGS: {lf_tags_table}") logger.debug(f"CONFIG TAGS: {self.lf_tags}") - to_remove = {tag["TagKey"]: tag["TagValues"] for tag in lf_tags_table if tag["TagKey"] not in self.lf_tags} + to_remove = { + tag["TagKey"]: tag["TagValues"] + for tag in lf_tags_table + if tag["TagKey"] not in self.lf_tags # type: ignore + } logger.debug(f"TAGS TO REMOVE: {to_remove}") if to_remove: response = self.lf_client.remove_lf_tags_from_resource( @@ -105,7 +109,7 @@ def _parse_lf_response( self, response: Union[AddLFTagsToResourceResponseTypeDef, RemoveLFTagsFromResourceResponseTypeDef], columns: Optional[List[str]] = None, - lf_tags: Dict[str, str] = None, + lf_tags: Optional[Dict[str, str]] = None, verb: str = "add", ) -> str: failures = response.get("Failures", []) @@ -195,7 +199,7 @@ def process_filters(self, config: LfGrantsConfig) -> None: for f in to_update: self.lf_client.update_data_cells_filter(TableData=f) - def process_permissions(self, config: LfGrantsConfig): + def process_permissions(self, config: LfGrantsConfig) -> None: for name, f in config.data_cell_filters.filters.items(): logger.debug(f"Start processing permissions for filter: {name}") current_permissions = self.lf_client.list_permissions( diff --git a/dbt/adapters/athena/relation.py b/dbt/adapters/athena/relation.py index 2d396ff9..bb07393f 100644 --- a/dbt/adapters/athena/relation.py +++ b/dbt/adapters/athena/relation.py @@ -32,9 +32,9 @@ class AthenaRelation(BaseRelation): include_policy: Policy = field(default_factory=lambda: AthenaIncludePolicy()) s3_path_table_part: Optional[str] = None - def render_hive(self): + def render_hive(self) -> str: """ - Render relation with Hive format. Athena uses Hive format for some DDL statements. + Render relation with Hive format. Athena uses a Hive format for some DDL statements. See: - https://aws.amazon.com/athena/faqs/ "Q: How do I create tables and schemas for my data on Amazon S3?" @@ -45,9 +45,9 @@ def render_hive(self): object.__setattr__(self, "quote_character", "`") # Hive quote char rendered = self.render() object.__setattr__(self, "quote_character", old_value) - return rendered + return str(rendered) - def render_pure(self): + def render_pure(self) -> str: """ Render relation without quotes characters. This is needed for not standard executions like optimize and vacuum @@ -56,20 +56,19 @@ def render_pure(self): object.__setattr__(self, "quote_character", "") rendered = self.render() object.__setattr__(self, "quote_character", old_value) - return rendered + return str(rendered) class AthenaSchemaSearchMap(Dict[InformationSchema, Dict[str, Set[Optional[str]]]]): """A utility class to keep track of what information_schema tables to search for what schemas and relations. The schema and relation values are all - lowercased to avoid duplication. + lowercase to avoid duplication. """ - def add(self, relation: AthenaRelation): + def add(self, relation: AthenaRelation) -> None: key = relation.information_schema_only() if key not in self: self[key] = {} - schema: Optional[str] = None if relation.schema is not None: schema = relation.schema.lower() relation_name = relation.name.lower() diff --git a/dbt/adapters/athena/utils.py b/dbt/adapters/athena/utils.py index ee3b20bc..778fb4c2 100644 --- a/dbt/adapters/athena/utils.py +++ b/dbt/adapters/athena/utils.py @@ -1,4 +1,4 @@ -from typing import Collection, List, Optional, TypeVar +from typing import Generator, List, Optional, TypeVar from mypy_boto3_athena.type_defs import DataCatalogTypeDef @@ -9,14 +9,13 @@ def clean_sql_comment(comment: str) -> str: def get_catalog_id(catalog: Optional[DataCatalogTypeDef]) -> Optional[str]: - if catalog: - return catalog["Parameters"]["catalog-id"] + return catalog["Parameters"]["catalog-id"] if catalog else None T = TypeVar("T") -def get_chunks(lst: Collection[T], n: int) -> Collection[List[T]]: +def get_chunks(lst: List[T], n: int) -> Generator[List[T], None, None]: """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i : i + n] diff --git a/setup.py b/setup.py index b4ff1191..dae9a695 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import os import re +from typing import Any, Dict from setuptools import find_namespace_packages, setup @@ -13,7 +14,7 @@ # get version from a separate file -def _get_plugin_version_dict(): +def _get_plugin_version_dict() -> Dict[str, Any]: _version_path = os.path.join(this_directory, "dbt", "adapters", "athena", "__version__.py") _semver = r"""(?P\d+)\.(?P\d+)\.(?P\d+)""" _pre = r"""((?Pa|b|rc)(?P

    \d+))?"""
    @@ -25,7 +26,7 @@ def _get_plugin_version_dict():
             return match.groupdict()
     
     
    -def _get_package_version():
    +def _get_package_version() -> str:
         parts = _get_plugin_version_dict()
         return f'{parts["major"]}.{parts["minor"]}.{parts["patch"]}'
     
    
    From ed56cfeae0a01eae4d2ea86a86da5e77f8e62eee Mon Sep 17 00:00:00 2001
    From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
    Date: Fri, 9 Jun 2023 14:24:04 +0200
    Subject: [PATCH 52/75] chore: Update dbt-tests-adapter requirement from
     ~=1.5.0 to ~=1.5.1 (#325)
    
    Signed-off-by: dependabot[bot] 
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    ---
     dev-requirements.txt | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/dev-requirements.txt b/dev-requirements.txt
    index c5074ee1..54ef92f8 100644
    --- a/dev-requirements.txt
    +++ b/dev-requirements.txt
    @@ -1,6 +1,6 @@
     autoflake~=1.7
     black~=23.3
    -dbt-tests-adapter~=1.5.0
    +dbt-tests-adapter~=1.5.1
     flake8~=5.0
     Flake8-pyproject~=1.2
     isort~=5.11
    
    From c24d65ad13b5f2aa549a5edc17c3b05776f6bbed Mon Sep 17 00:00:00 2001
    From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com>
    Date: Sat, 10 Jun 2023 21:01:01 -0300
    Subject: [PATCH 53/75] Fixed readme. Moved some defaults to constants.
    
    ---
     .env.example                              |   1 +
     .python-version                           |   1 -
     README.md                                 | 129 +++++++++++++++-------
     dbt/adapters/athena/config.py             |  68 ++++++++++--
     dbt/adapters/athena/constants.py          |   3 +
     dbt/adapters/athena/python_submissions.py |  42 ++++---
     dbt/adapters/athena/session.py            |  20 ++++
     tests/conftest.py                         |   3 +
     tests/unit/constants.py                   |   1 +
     tests/unit/test_session.py                |  12 +-
     10 files changed, 202 insertions(+), 78 deletions(-)
     delete mode 100644 .python-version
    
    diff --git a/.env.example b/.env.example
    index 641f3899..68890982 100644
    --- a/.env.example
    +++ b/.env.example
    @@ -7,3 +7,4 @@ DBT_TEST_ATHENA_SCHEMA=
     DBT_TEST_ATHENA_WORK_GROUP=
     DBT_TEST_ATHENA_AWS_PROFILE_NAME=
     DBT_TEST_ATHENA_SPARK_WORK_GROUP=
    +DBT_TEST_ATHENA_SPARK_THREADs=
    diff --git a/.python-version b/.python-version
    deleted file mode 100644
    index 1281604a..00000000
    --- a/.python-version
    +++ /dev/null
    @@ -1 +0,0 @@
    -3.10.7
    diff --git a/README.md b/README.md
    index 50a9592b..2a8c3928 100644
    --- a/README.md
    +++ b/README.md
    @@ -6,13 +6,6 @@
         
         
     

    -

    - - - - - -

    ## Features @@ -91,8 +84,8 @@ A dbt profile can be configured to run against AWS Athena using the following co | aws_profile_name | Profile to use from your AWS shared credentials file. | Optional | `my-profile` | | work_group | Identifier of Athena workgroup | Optional | `my-custom-workgroup` | | num_retries | Number of times to retry a failing query | Optional | `3` | -| lf_tags | Default lf tags to apply to any database created by dbt | Optional | `{"origin": "dbt", "team": "analytics"}` | -| spark_work_group | Identifier of athena spark workgroup | Optional | `my-spark-workgroup` | +| spark_work_group | Identifier of Athena Spark workgroup | Optional | `my-spark-workgroup` | +| spark_threads | Number of spark sessions to create. Recommended to be same as threads. | Optional | `4` | **Example profiles.yml entry:** ```yaml @@ -107,12 +100,11 @@ athena: region_name: eu-west-1 schema: dbt database: awsdatacatalog + threads: 4 aws_profile_name: my-profile work_group: my-workgroup spark_work_group: my-spark-workgroup - lf_tags: - origin: dbt - team: analytics + spark_threads: 4 ``` _Additional information_ @@ -391,6 +383,90 @@ You may find the following links useful to manage that: - [terraform aws_lakeformation_permissions](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lakeformation_permissions) - [terraform aws_lakeformation_resource_lf_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lakeformation_resource_lf_tags) +## Python Models + +The adapter supports python models using [`spark`](https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark.html). + +#### Prerequisites + +* A spark enabled work group created in athena +* Spark execution role granted access to Athena, Glue and S3 +* The spark work group is added to the ~/.dbt/profiles.yml file and the profile is referenced in dbt_project.yml +* The spark_threads value is added to ~/.dbt/profiles.yml which determines the maximum number of parallel spark sessions that will be created. It is recommended to keep this same as threads. + +### Example models + +#### Simple pandas model + +```python +import pandas as pd + + +def model(dbt, session): + dbt.config(materialized="table") + + model_df = pd.DataFrame({"A": [1, 2, 3, 4]}) + + return model_df +``` + +#### Simple spark + +```python +def model(dbt, spark_session): + dbt.config(materialized="table") + + data = [(1,), (2,), (3,), (4,)] + + df = spark_session.createDataFrame(data, ["A"]) + + return df +``` + +#### Spark incremental + +```python +def model(dbt, spark_session): + dbt.config(materialized="incremental") + df = dbt.ref("model") + + if dbt.is_incremental: + max_from_this = ( + f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" + ) + df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) + + return df +``` + +#### Config spark model + +```python +def model(dbt, spark_session): + dbt.config( + materialized="table", + engine_config={ + "CoordinatorDpuSize": 1, + "MaxConcurrentDpus": 3, + "DefaultExecutorDpuSize": 1, + }, + polling_interval=15, + timeout=120, + ) + + data = [(1,), (2,), (3,), (4,)] + + df = spark_session.createDataFrame(data, ["A"]) + + return df +``` + +#### Known issues in python models + +* Incremental models do not fully utilize spark capabilities. They depend partially on existing sql based logic. +* Snapshots materializations are not supported. +* Spark can only reference tables within the same catalog. + ### Working example seed file - employent_indicators_november_2022_csv_tables.csv @@ -498,35 +574,6 @@ The only way, from a dbt perspective, is to do a full-refresh of the incremental * Snapshot does not support dropping columns from the source table. If you drop a column make sure to drop the column from the snapshot as well. Another workaround is to NULL the column in the snapshot definition to preserve history -### Python Models - -The adapter supports python models using [`spark`](https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark.html). - -#### Prerequisites - -* A spark enabled work group created in athena -* Spark execution role granted access to Athena, Glue and S3 -* The spark work group is added to the ~/.dbt/profiles.yml file and the profile is referenced in dbt_project.yml - -#### Example model - -```python -import pandas as pd - - -def model(dbt, session): - dbt.config(materialized="table") - - model_df = pd.DataFrame({"A": [1, 2, 3, 4]}) - - return model_df -``` - -#### Known issues in python models - -* Incremental models do not fully utilize spark capabilities. They depend on existing sql based logic. -* Snapshots materializations are not supported. - ## Contracts diff --git a/dbt/adapters/athena/config.py b/dbt/adapters/athena/config.py index 8e5aff39..595e9a1f 100644 --- a/dbt/adapters/athena/config.py +++ b/dbt/adapters/athena/config.py @@ -3,11 +3,12 @@ import pkg_resources from botocore import config -from dbt.adapters.athena.constants import LOGGER - -DEFAULT_POLLING_INTERVAL = 5 -DEFAULT_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} -DEFAULT_TIMEOUT = 60 * 60 * 2 +from dbt.adapters.athena.constants import ( + DEFAULT_POLLING_INTERVAL, + DEFAULT_SPARK_ENGINE_CONFIG, + DEFAULT_SPARK_SESSION_TIMEOUT, + LOGGER, +) @lru_cache() @@ -22,8 +23,13 @@ class AthenaSparkSessionConfig: A helper class to manage Athena Spark Session Configuration. """ +<<<<<<< HEAD def __init__(self, config: dict) -> None: +======= + def __init__(self, config: Dict[str, Any], **session_kwargs: Any) -> None: +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) self.config = config + self.session_kwargs = session_kwargs def set_timeout(self) -> int: """ @@ -40,7 +46,7 @@ def set_timeout(self) -> int: ValueError: If the timeout value is not a positive integer. """ - timeout = self.config.get("timeout", DEFAULT_TIMEOUT) + timeout = self.config.get("timeout", DEFAULT_SPARK_SESSION_TIMEOUT) if not isinstance(timeout, int): raise TypeError("Timeout must be an integer") if timeout <= 0: @@ -48,15 +54,57 @@ def set_timeout(self) -> int: LOGGER.debug(f"Setting timeout: {timeout}") return timeout + def get_polling_interval(self) -> Any: + """ + Get the polling interval for the configuration. + + Returns: + Any: The polling interval value. + + Raises: + KeyError: If the polling interval is not found in either `self.config` + or `self.session_kwargs`. + """ + try: + return self.config["polling_interval"] + except KeyError: + try: + return self.session_kwargs["polling_interval"] + except KeyError: + return DEFAULT_POLLING_INTERVAL + def set_polling_interval(self) -> float: - polling_interval = self.config.get("polling_interval", DEFAULT_POLLING_INTERVAL) - if not isinstance(polling_interval, int) or polling_interval <= 0: - raise ValueError("Polling_interval must be a positive integer") + """ + Set the polling interval for the configuration. + + Returns: + float: The polling interval value. + + Raises: + ValueError: If the polling interval is not a positive integer. + """ + polling_interval = self.get_polling_interval() + if not (isinstance(polling_interval, float) or isinstance(polling_interval, int)) or polling_interval <= 0: + raise ValueError(f"Polling_interval must be a positive number. Got: {polling_interval}") LOGGER.debug(f"Setting polling_interval: {polling_interval}") - return polling_interval + return float(polling_interval) +<<<<<<< HEAD def set_engine_config(self) -> dict: engine_config = self.config.get("engine_config", DEFAULT_ENGINE_CONFIG) +======= + def set_engine_config(self) -> Dict[str, Any]: + """Set the engine configuration. + + Returns: + Dict[str, Any]: The engine configuration. + + Raises: + TypeError: If the engine configuration is not of type dict. + KeyError: If the keys of the engine configuration dictionary do not match the expected format. + """ + engine_config = self.config.get("engine_config", DEFAULT_SPARK_ENGINE_CONFIG) +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) if not isinstance(engine_config, dict): raise TypeError("Engine configuration has to be of type dict") diff --git a/dbt/adapters/athena/constants.py b/dbt/adapters/athena/constants.py index df6bd86b..b2d9b7e1 100644 --- a/dbt/adapters/athena/constants.py +++ b/dbt/adapters/athena/constants.py @@ -2,5 +2,8 @@ DEFAULT_THREAD_COUNT = 4 DEFAULT_RETRY_ATTEMPTS = 3 +DEFAULT_POLLING_INTERVAL = 5 +DEFAULT_SPARK_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} +DEFAULT_SPARK_SESSION_TIMEOUT = 15 * 60 LOGGER = AdapterLogger(__name__) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index e4dc2b2e..4119b7b1 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -16,35 +16,23 @@ class AthenaPythonJobHelper(PythonJobHelper): """ - A helper class for executing Python jobs on AWS Athena. - - This class extends the base `PythonJobHelper` class and provides additional functionality - specific to executing jobs on Athena. It takes a parsed model and credentials as inputs - during initialization, and provides methods for executing Athena jobs, setting timeout, - polling interval, region name, AWS profile name, and Spark work group. + Default helper to execute python models with Athena Spark. Args: - parsed_model (Dict): A dictionary representing the parsed model of the Athena job. - It should contain keys such as 'alias' for job identifier and 'schema' for - job schema. - credentials (AthenaCredentials): An instance of the `AthenaCredentials` class - containing AWS credentials for accessing Athena. - - Attributes: - identifier (str): A string representing the alias or identifier of the Athena job. - schema (str): A string representing the schema of the Athena job. - parsed_model (Dict): A dictionary representing the parsed model of the Athena job. - timeout (int): An integer representing the timeout value in seconds for the Athena job. - polling_interval (int): An integer representing the polling interval in seconds for - checking the status of the Athena job. - region_name (str): A string representing the AWS region name for executing the Athena job. - profile_name (str): A string representing the AWS profile name for accessing Athena. - spark_work_group (str): A string representing the Spark work group for executing the Athena job. - + PythonJobHelper (PythonJobHelper): The base python helper class """ +<<<<<<< HEAD def __init__(self, parsed_model: Dict, credentials: AthenaCredentials) -> None: self.config = AthenaSparkSessionConfig(parsed_model.get("config", {})) +======= + def __init__(self, parsed_model: Dict[Any, Any], credentials: AthenaCredentials) -> None: + self.config = AthenaSparkSessionConfig( + parsed_model.get("config", {}), + polling_interval=credentials.poll_interval, + retry_attempts=credentials.num_retries, + ) +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) self.spark_connection = AthenaSparkSessionManager( credentials, self.timeout, self.polling_interval, self.engine_config ) @@ -70,7 +58,15 @@ def polling_interval(self) -> float: def engine_config(self) -> Dict[str, int]: return self.config.set_engine_config() +<<<<<<< HEAD def get_current_session_status(self) -> Dict[str, Any]: +======= + @cached_property + def athena_client(self) -> Any: + return self.spark_connection.athena_client + + def get_current_session_status(self) -> Any: +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) """ Get the current session status. diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 2068fc42..76e536a9 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -43,7 +43,11 @@ def get_max_session_count(self): def get_spark_work_group(self): if not self.credentials.spark_work_group: +<<<<<<< HEAD raise DbtRuntimeError("Expected spark_work_group in profile") +======= + raise DbtRuntimeError(f"Expected spark_work_group in profile. Got: {self.credentials.spark_work_group}") +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) return self.credentials.spark_work_group def get_athena_client(self): @@ -72,6 +76,17 @@ def get_new_sessions(self) -> List[UUID]: sessions = self.list_sessions() existing_sessions = set(spark_session_locks.keys()) new_sessions = [session for session in sessions if session not in existing_sessions] +<<<<<<< HEAD +======= + + if len(new_sessions) == 0: + if len(spark_session_locks) < self.spark_threads: + return [self.start_session()] + LOGGER.warning( + f"""Maximum spark session count: {self.spark_threads} reached. + Cannot start new spark session.""" + ) +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) LOGGER.debug(f"Setting sessions: {new_sessions}") return new_sessions @@ -138,6 +153,7 @@ def list_sessions(self, state: str = "IDLE") -> List[UUID]: MaxResults=max_results, StateFilter=state, ) +<<<<<<< HEAD if len(response.get("Sessions")) == 0 or response.get("Sessions") is None: if len(spark_session_locks) < max_results: return [self.start_session()] @@ -145,6 +161,10 @@ def list_sessions(self, state: str = "IDLE") -> List[UUID]: f"""Maximum spark session count: {max_results} reached. Cannot start new spark session.""" ) +======= + if response.get("Sessions") is None: + return [] +>>>>>>> 5f40faf (Fixed readme. Moved some defaults to constants.) return [UUID(session_string["SessionId"]) for session_string in response.get("Sessions")] def start_session(self) -> UUID: diff --git a/tests/conftest.py b/tests/conftest.py index 9fa88df2..3ca4d14a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ DATA_CATALOG_NAME, DATABASE_NAME, S3_STAGING_DIR, + SPARK_THREADS, SPARK_WORKGROUP, ) @@ -41,6 +42,7 @@ def dbt_profile_target(): "work_group": os.getenv("DBT_TEST_ATHENA_WORK_GROUP"), "aws_profile_name": os.getenv("DBT_TEST_ATHENA_AWS_PROFILE_NAME") or None, "spark_work_group": os.getenv("DBT_TEST_ATHENA_SPARK_WORK_GROUP"), + "spark_threads": os.getenv("DBT_TEST_ATHENA_SPARK_THREADS"), } @@ -82,4 +84,5 @@ def athena_credentials(): region_name=AWS_REGION, work_group=ATHENA_WORKGROUP, spark_work_group=SPARK_WORKGROUP, + spark_threads=SPARK_THREADS, ) diff --git a/tests/unit/constants.py b/tests/unit/constants.py index 21bac432..51deab6f 100644 --- a/tests/unit/constants.py +++ b/tests/unit/constants.py @@ -7,3 +7,4 @@ S3_STAGING_DIR = "s3://my-bucket/test-dbt/" ATHENA_WORKGROUP = "dbt-athena-adapter" SPARK_WORKGROUP = "spark" +SPARK_THREADS = 4 diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index cd4681fb..49d910c8 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -96,7 +96,11 @@ def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): { "Sessions": [], }, - [UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7")], + [], + ), + ( + {}, + [], ), ], ) @@ -238,12 +242,12 @@ def test_get_session_status(self, session_status_response, expected_status, spar ( [], {}, - [], + [UUID("39cb8fc0-f855-4b67-91f1-81f068499071")], ), ( [UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): "lock"}, - [], + [UUID("39cb8fc0-f855-4b67-91f1-81f068499071")], ), ], ) @@ -277,6 +281,8 @@ def test_get_sessions( with patch.multiple( spark_session_manager, list_sessions=Mock(return_value=list_sessions_response), + poll_until_session_creation=Mock(return_value="IDLE"), + start_session=Mock(return_value=UUID("39cb8fc0-f855-4b67-91f1-81f068499071")), ): sessions = spark_session_manager.get_new_sessions() assert sessions == expected_new_sessions From 209e02c28c016f594887569c26d759565e4c6bd3 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Wed, 14 Jun 2023 01:08:06 -0300 Subject: [PATCH 54/75] Add more docs and functional tests. --- dbt/adapters/athena/python_submissions.py | 36 ++++---- dbt/adapters/athena/session.py | 64 +++++++++---- .../models/incremental/incremental.sql | 50 +++++------ .../adapter/test_python_submissions.py | 89 +++++++++++++++++++ 4 files changed, 176 insertions(+), 63 deletions(-) create mode 100644 tests/functional/adapter/test_python_submissions.py diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 1f3df90f..8acd2e6a 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -24,11 +24,11 @@ class AthenaPythonJobHelper(PythonJobHelper): def __init__(self, parsed_model: Dict[Any, Any], credentials: AthenaCredentials) -> None: """ - _summary_ + Initialize spark config and connection. Args: - parsed_model (Dict[Any, Any]): _description_ - credentials (AthenaCredentials): _description_ + parsed_model (Dict[Any, Any]): The parsed python model. + credentials (AthenaCredentials): Credentials for Athena connection. """ self.config = AthenaSparkSessionConfig( parsed_model.get("config", {}), @@ -42,50 +42,50 @@ def __init__(self, parsed_model: Dict[Any, Any], credentials: AthenaCredentials) @cached_property def timeout(self) -> int: """ - _summary_ + Get the timeout value. Returns: - int: _description_ + int: The timeout value in seconds. """ return self.config.set_timeout() @cached_property def session_id(self) -> str: """ - _summary_ + Get the session ID. Returns: - str: _description_ + str: The session ID as a string. """ return str(self.spark_connection.get_session_id()) @cached_property def polling_interval(self) -> float: """ - _summary_ + Get the polling interval. Returns: - float: _description_ + float: The polling interval in seconds. """ return self.config.set_polling_interval() @cached_property def engine_config(self) -> Dict[str, int]: """ - _summary_ + Get the engine configuration. Returns: - Dict[str, int]: _description_ + Dict[str, int]: A dictionary containing the engine configuration. """ return self.config.set_engine_config() @cached_property def athena_client(self) -> Any: """ - _summary_ + Get the Athena client. Returns: - Any: _description_ + Any: The Athena client object. """ return self.spark_connection.athena_client @@ -94,11 +94,17 @@ def get_current_session_status(self) -> Any: Get the current session status. Returns: - str: The status of the session + Any: The status of the session """ return self.spark_connection.get_session_status(self.session_id) def poll_until_session_idle(self) -> None: + """ + Polls the session status until it becomes idle or exceeds the timeout. + + Raises: + DbtRuntimeError: If the session chosen is not available or if it does not become idle within the timeout. + """ polling_interval = self.polling_interval while True: session_status = self.get_current_session_status()["State"] @@ -117,7 +123,7 @@ def submit(self, compiled_code: str) -> Any: This function submits a calculation to Athena for execution using the provided compiled code. It starts a calculation execution with the current session ID and the compiled code as the code block. - The function then polls until the calculation execution is completed, and retrieves the result S3 URI. + The function then polls until the calculation execution is completed, and retrieves the result. If the execution is successful and completed, the result S3 URI is returned. Otherwise, a DbtRuntimeError is raised with the execution status. diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 5754924e..fb5a9fac 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -30,6 +30,16 @@ class AthenaSparkSessionManager: """ def __init__(self, credentials: Any, timeout: int, polling_interval: float, engine_config: Dict[str, int]) -> None: + """ + Initialize the AthenaSparkSessionManager instance. + + Args: + credentials (Any): The credentials to be used. + timeout (int): The timeout value in seconds. + polling_interval (float): The polling interval in seconds. + engine_config (Dict[str, int]): The engine configuration. + + """ self.credentials = credentials self.timeout = timeout self.polling_interval = polling_interval @@ -37,20 +47,32 @@ def __init__(self, credentials: Any, timeout: int, polling_interval: float, engi self.lock = threading.Lock() @cached_property - def spark_threads(self) -> Any: + def spark_threads(self) -> int: + """ + Get the number of Spark threads. + + Returns: + Any: The number of Spark threads. If not found in the profile, returns the default thread count. + """ if not self.credentials.spark_threads: LOGGER.debug( f"""Spark threads not found in profile. Got: {self.credentials.spark_threads}. Using default count: {DEFAULT_THREAD_COUNT}""" ) return DEFAULT_THREAD_COUNT - return self.credentials.spark_threads + return int(self.credentials.spark_threads) @cached_property - def spark_work_group(self) -> Any: + def spark_work_group(self) -> str: + """ + Get the Spark work group. + + Returns: + Any: The Spark work group. Raises an exception if not found in the profile. + """ if not self.credentials.spark_work_group: raise DbtRuntimeError(f"Expected spark_work_group in profile. Got: {self.credentials.spark_work_group}") - return self.credentials.spark_work_group + return str(self.credentials.spark_work_group) @cached_property def athena_client(self) -> Any: @@ -71,6 +93,8 @@ def athena_client(self) -> Any: def get_new_sessions(self) -> List[UUID]: """ Retrieves a list of new sessions by subtracting the existing sessions from the complete session list. + If no new sessions are found a new session is created provided the number of sessions is less than the + number of allowed spark threads. Returns: List[UUID]: A list of new session UUIDs. @@ -95,8 +119,7 @@ def update_spark_session_locks(self) -> None: Update session locks for each session. This function iterates over the existing sessions and ensures that a session lock is created for each session. - If a session lock already exists, it is left unchanged. After updating the session locks, - a debug log is generated to display the updated session locks. + If a session lock already exists, it is left unchanged. Args: self: The instance of the class. @@ -110,13 +133,15 @@ def update_spark_session_locks(self) -> None: def get_session_id(self) -> UUID: """ - Get the session ID. - - This function retrieves the session ID from the stored session information. If session information - is not available, a new session is started and its session ID is returned. + Get a session ID for the Spark session. Returns: - str: The session ID. + UUID: The session ID. + + Notes: + This method acquires a lock on an existing available Spark session. If no session is available, + it waits for a certain period and retries until a session becomes available or a timeout is reached. + The session ID of the acquired session is returned. """ polling_interval = self.polling_interval @@ -136,15 +161,18 @@ def get_session_id(self) -> UUID: def list_sessions(self, state: str = "IDLE") -> List[UUID]: """ - List athena spark sessions. + List the sessions based on the specified state. - This function sends a request to the Athena service to list the sessions in the specified Spark workgroup. - It filters the sessions by state, only returning the first session that is in IDLE state. If no idle sessions - are found or if an error occurs, None is returned. + Args: + state (str, optional): The state to filter the sessions. Defaults to "IDLE". Returns: - List[UUID]: A list of session UUIDs if an idle sessions are found, else start a new session and return - the list of the returned session id. + List[UUID]: A list of session IDs matching the specified state. + + Notes: + This method utilizes the Athena client to list sessions in the Spark work group. + The sessions are filtered based on the provided state. + If no sessions are found or the response does not contain any sessions, an empty list is returned. """ response = self.athena_client.list_sessions( @@ -232,6 +260,6 @@ def get_session_status(self, session_id: str) -> Any: Get the session status. Returns: - str: The status of the session + Any: The status of the session """ return self.athena_client.get_session_status(SessionId=session_id)["Status"] diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index b9c2a7e4..7cfc06d4 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -1,8 +1,8 @@ {% materialization incremental, adapter='athena', supported_languages=['sql', 'python'] -%} - {%- set language = model['language'] -%} {% set raw_strategy = config.get('incremental_strategy') or 'insert_overwrite' %} {% set table_type = config.get('table_type', default='hive') | lower %} + {% set model_language = model['language'] %} {% set strategy = validate_get_incremental_strategy(raw_strategy, table_type) %} {% set on_schema_change = incremental_validate_on_schema_change(config.get('on_schema_change'), default='ignore') %} @@ -13,8 +13,6 @@ {% set existing_relation = load_relation(this) %} {% set tmp_relation = make_temp_relation(this) %} - {{ log("temporary relation is" ~ tmp_relation.schema ~ tmp_relation.identifier)}} - -- If no partitions are used with insert_overwrite, we fall back to append mode. {% if partitioned_by is none and strategy == 'insert_overwrite' %} {% set strategy = 'append' %} @@ -27,26 +25,20 @@ {% set to_drop = [] %} {% if existing_relation is none %} - {% call statement('save_table', language=language) -%} - {{ create_table_as(False, target_relation, compiled_code, language) }} - {%- endcall %} - {% set build_sql = None %} + {% set build_sql = create_table_as(False, target_relation, compiled_code, model_language) -%} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} - {% call statement('save_table', language=language) -%} - {{ create_table_as(False, target_relation, compiled_code, language) }} - {%- endcall %} - {% set build_sql = None %} + {% set build_sql = create_table_as(False, target_relation, compiled_code, model_language) -%} {% elif partitioned_by is not none and strategy == 'insert_overwrite' %} {% set tmp_relation = make_temp_relation(target_relation) %} {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% if language == 'sql' %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if model_language == "sql" %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, model_language)) %} {% else %} - {% call statement('save_table', language=language) -%} - {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {% call statement('py_save_table', language=model_language) -%} + {{ create_table_as(False, target_relation, compiled_code, model_language) }} {%- endcall %} {% endif %} {% do delete_overlapping_partitions(target_relation, tmp_relation, partitioned_by) %} @@ -57,11 +49,11 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% if language == 'sql' %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if model_language == "sql" %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, model_language)) %} {% else %} - {% call statement('save_table', language=language) -%} - {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {% call statement('py_save_table', language=model_language) -%} + {{ create_table_as(False, target_relation, compiled_code, model_language) }} {%- endcall %} {% endif %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} @@ -80,30 +72,28 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% if language == 'sql' %} - {% do run_query(create_table_as(True, tmp_relation, compiled_code, language)) %} + {% if model_language == "sql" %} + {% do run_query(create_table_as(True, tmp_relation, compiled_code, model_language)) %} {% else %} - {% call statement('save_table', language=language) -%} - {{ create_table_as(True, tmp_relation, compiled_code, language) }} + {% call statement('py_save_table', language=model_language) -%} + {{ create_table_as(False, target_relation, compiled_code, model_language) }} {%- endcall %} {% endif %} {% set build_sql = iceberg_merge(on_schema_change, tmp_relation, target_relation, unique_key, existing_relation, delete_condition) %} {% do to_drop.append(tmp_relation) %} {% endif %} - {% call statement("main", language=language) %} - {% if language == 'sql' %} + {% call statement("main", language=model_language) %} + {% if model_language == 'sql' %} {{ build_sql }} {% else %} - {% if build_sql %} - {{ log(build_sql) }} - {% do athena__py_execute_query(query=build_sql) %} - {% endif %} + {{ log(build_sql) }} + {% do athena__py_execute_query(query=build_sql) %} {% endif %} {% endcall %} -- set table properties - {% if not to_drop and table_type != 'iceberg' %} + {% if not to_drop and table_type != 'iceberg' and model_language != 'python' %} {{ set_table_classification(target_relation) }} {% endif %} diff --git a/tests/functional/adapter/test_python_submissions.py b/tests/functional/adapter/test_python_submissions.py new file mode 100644 index 00000000..4011bc97 --- /dev/null +++ b/tests/functional/adapter/test_python_submissions.py @@ -0,0 +1,89 @@ +import pytest +import yaml + +from dbt.tests.adapter.python_model.test_python_model import ( + BasePythonIncrementalTests, + BasePythonModelTests, +) +from dbt.tests.util import run_dbt + +basic_sql = """ +{{ config(materialized="table") }} +select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date +""" + +basic_python = """ +def model(dbt, spark): + dbt.config( + materialized='table', + ) + df = dbt.ref("model") + return df +""" + +basic_spark_python = """ +def model(dbt, spark_session): + dbt.config(materialized="table") + + data = [(1,), (2,), (3,), (4,)] + + df = spark_session.createDataFrame(data, ["A"]) + + return df +""" + +second_sql = """ +select * from {{ref('my_python_model')}} +""" + +schema_yml = """version: 2 +models: + - name: model + versions: + - v: 1 +""" + + +class TestBasePythonModelTests(BasePythonModelTests): + @pytest.fixture(scope="class") + def models(self): + return { + "schema.yml": schema_yml, + "model.sql": basic_sql, + "my_python_model.py": basic_python, + "spark_model.py": basic_spark_python, + "second_sql_model.sql": second_sql, + } + + +incremental_python = """ +def model(dbt, spark_session): + dbt.config(materialized="incremental") + df = dbt.ref("model") + + if dbt.is_incremental: + max_from_this = ( + f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" + ) + df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) + + return df +""" + + +class TestBasePythonIncrementalTests(BasePythonIncrementalTests): + @pytest.fixture(scope="class") + def project_config_update(self): + return {"models": {"+incremental_strategy": "append"}} + + @pytest.fixture(scope="class") + def models(self): + return {"model.sql": basic_sql, "incremental.py": incremental_python} + + def test_incremental(self, project): + vars_dict = { + "test_run_schema": project.test_schema, + } + + results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) + assert len(results) == 2 From f56c2e84860ea3e6c71665e1cd3cd6a2a8eb6eed Mon Sep 17 00:00:00 2001 From: Personal Date: Thu, 28 Sep 2023 07:43:13 -0400 Subject: [PATCH 55/75] Used default logger --- dbt/adapters/athena/connections.py | 2 ++ dbt/adapters/athena/lakeformation.py | 38 +++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index c639abf7..4c0b57d1 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -35,6 +35,8 @@ from dbt.adapters.sql import SQLConnectionManager from dbt.contracts.connection import AdapterResponse, Connection, ConnectionState from dbt.exceptions import ConnectionError, DbtRuntimeError +from dbt.events import AdapterLogger + logger = AdapterLogger("Athena") diff --git a/dbt/adapters/athena/lakeformation.py b/dbt/adapters/athena/lakeformation.py index 9fc8047b..bae370cb 100644 --- a/dbt/adapters/athena/lakeformation.py +++ b/dbt/adapters/athena/lakeformation.py @@ -14,11 +14,9 @@ from pydantic import BaseModel from dbt.adapters.athena.relation import AthenaRelation -from dbt.events import AdapterLogger +from dbt.adapters.athena.constants import LOGGER from dbt.exceptions import DbtRuntimeError -logger = AdapterLogger("AthenaLakeFormation") - class LfTagsConfig(BaseModel): enabled: bool = False @@ -51,7 +49,7 @@ def process_lf_tags(self) -> None: def _remove_lf_tags_columns(self, existing_lf_tags: GetResourceLFTagsResponseTypeDef) -> None: lf_tags_columns = existing_lf_tags.get("LFTagsOnColumns", []) - logger.debug(f"COLUMNS: {lf_tags_columns}") + LOGGER.debug(f"COLUMNS: {lf_tags_columns}") if lf_tags_columns: to_remove = {} for column in lf_tags_columns: @@ -64,7 +62,7 @@ def _remove_lf_tags_columns(self, existing_lf_tags: GetResourceLFTagsResponseTyp to_remove[tag_key][tag_value] = [column["Name"]] else: to_remove[tag_key][tag_value].append(column["Name"]) - logger.debug(f"TO REMOVE: {to_remove}") + LOGGER.debug(f"TO REMOVE: {to_remove}") for tag_key, tag_config in to_remove.items(): for tag_value, columns in tag_config.items(): resource = { @@ -79,15 +77,15 @@ def _apply_lf_tags_table( self, table_resource: ResourceTypeDef, existing_lf_tags: GetResourceLFTagsResponseTypeDef ) -> None: lf_tags_table = existing_lf_tags.get("LFTagsOnTable", []) - logger.debug(f"EXISTING TABLE TAGS: {lf_tags_table}") - logger.debug(f"CONFIG TAGS: {self.lf_tags}") + LOGGER.debug(f"EXISTING TABLE TAGS: {lf_tags_table}") + LOGGER.debug(f"CONFIG TAGS: {self.lf_tags}") to_remove = { tag["TagKey"]: tag["TagValues"] for tag in lf_tags_table if tag["TagKey"] not in self.lf_tags # type: ignore } - logger.debug(f"TAGS TO REMOVE: {to_remove}") + LOGGER.debug(f"TAGS TO REMOVE: {to_remove}") if to_remove: response = self.lf_client.remove_lf_tags_from_resource( Resource=table_resource, LFTags=[{"TagKey": k, "TagValues": v} for k, v in to_remove.items()] @@ -128,9 +126,9 @@ def _parse_and_log_lf_response( for failure in failures: tag = failure.get("LFTag", {}).get("TagKey") error = failure.get("Error", {}).get("ErrorMessage") - logger.error(f"Failed to {verb} {tag} for " + resource_msg + f" - {error}") + LOGGER.error(f"Failed to {verb} {tag} for " + resource_msg + f" - {error}") raise DbtRuntimeError(base_msg) - logger.debug(f"Success: {verb} LF tags {lf_tags} to " + resource_msg) + LOGGER.debug(f"Success: {verb} LF tags {lf_tags} to " + resource_msg) class FilterConfig(BaseModel): @@ -178,10 +176,10 @@ def get_filters(self) -> Dict[str, DataCellsFilterTypeDef]: def process_filters(self, config: LfGrantsConfig) -> None: current_filters = self.get_filters() - logger.debug(f"CURRENT FILTERS: {current_filters}") + LOGGER.debug(f"CURRENT FILTERS: {current_filters}") to_drop = [f for name, f in current_filters.items() if name not in config.data_cell_filters.filters] - logger.debug(f"FILTERS TO DROP: {to_drop}") + LOGGER.debug(f"FILTERS TO DROP: {to_drop}") for f in to_drop: self.lf_client.delete_data_cells_filter( TableCatalogId=f["TableCatalogId"], @@ -195,7 +193,7 @@ def process_filters(self, config: LfGrantsConfig) -> None: for name, f in config.data_cell_filters.filters.items() if name not in current_filters ] - logger.debug(f"FILTERS TO ADD: {to_add}") + LOGGER.debug(f"FILTERS TO ADD: {to_add}") for f in to_add: self.lf_client.create_data_cells_filter(TableData=f) @@ -204,13 +202,13 @@ def process_filters(self, config: LfGrantsConfig) -> None: for name, f in config.data_cell_filters.filters.items() if name in current_filters and f.to_update(current_filters[name]) ] - logger.debug(f"FILTERS TO UPDATE: {to_update}") + LOGGER.debug(f"FILTERS TO UPDATE: {to_update}") for f in to_update: self.lf_client.update_data_cells_filter(TableData=f) def process_permissions(self, config: LfGrantsConfig) -> None: for name, f in config.data_cell_filters.filters.items(): - logger.debug(f"Start processing permissions for filter: {name}") + LOGGER.debug(f"Start processing permissions for filter: {name}") current_permissions = self.lf_client.list_permissions( Resource={ "DataCellsFilter": { @@ -231,9 +229,9 @@ def process_permissions(self, config: LfGrantsConfig) -> None: Entries=[self._permission_entry(name, principal, idx) for idx, principal in enumerate(to_revoke)], ) revoke_principals_msg = "\n".join(to_revoke) - logger.debug(f"Revoked permissions for filter {name} from principals:\n{revoke_principals_msg}") + LOGGER.debug(f"Revoked permissions for filter {name} from principals:\n{revoke_principals_msg}") else: - logger.debug(f"No redundant permissions found for filter: {name}") + LOGGER.debug(f"No redundant permissions found for filter: {name}") to_add = {p for p in f.principals if p not in current_principals} if to_add: @@ -242,11 +240,11 @@ def process_permissions(self, config: LfGrantsConfig) -> None: Entries=[self._permission_entry(name, principal, idx) for idx, principal in enumerate(to_add)], ) add_principals_msg = "\n".join(to_add) - logger.debug(f"Granted permissions for filter {name} to principals:\n{add_principals_msg}") + LOGGER.debug(f"Granted permissions for filter {name} to principals:\n{add_principals_msg}") else: - logger.debug(f"No new permissions added for filter {name}") + LOGGER.debug(f"No new permissions added for filter {name}") - logger.debug(f"Permissions are set to be consistent with config for filter: {name}") + LOGGER.debug(f"Permissions are set to be consistent with config for filter: {name}") def _permission_entry(self, filter_name: str, principal: str, idx: int) -> BatchPermissionsRequestEntryTypeDef: return { From 81d499a1f9f1c717bcba1717f0a59bb5b2357872 Mon Sep 17 00:00:00 2001 From: Personal Date: Thu, 28 Sep 2023 07:44:03 -0400 Subject: [PATCH 56/75] Used default logger --- dbt/adapters/athena/connections.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index 4c0b57d1..cf506011 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -35,10 +35,6 @@ from dbt.adapters.sql import SQLConnectionManager from dbt.contracts.connection import AdapterResponse, Connection, ConnectionState from dbt.exceptions import ConnectionError, DbtRuntimeError -from dbt.events import AdapterLogger - - -logger = AdapterLogger("Athena") @dataclass @@ -118,7 +114,7 @@ def _poll(self, query_id: str) -> AthenaQueryExecution: query_execution = self.__poll(query_id) except KeyboardInterrupt as e: if self._kill_on_interrupt: - logger.warning("Query canceled by user.") + LOGGER.warning("Query canceled by user.") self._cancel(query_id) query_execution = self.__poll(query_id) else: @@ -136,7 +132,7 @@ def __poll(self, query_id: str) -> AthenaQueryExecution: return query_execution else: if self.connection.cursor_kwargs.get("debug_query_state", False): - logger.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") + LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") time.sleep(self._poll_interval) def execute( # type: ignore @@ -279,7 +275,7 @@ def process_query_stats(cursor: AthenaCursor) -> Tuple[int, int]: stats = json.loads("{" + query_stats.group(1) + "}") return stats.get("rowcount", -1), stats.get("data_scanned_in_bytes", 0) except Exception as err: - logger.debug(f"There was an error parsing query stats {err}") + LOGGER.debug(f"There was an error parsing query stats {err}") return -1, 0 return cursor.rowcount, cursor.data_scanned_in_bytes From 213ee656930fb1a93e8468be465ff4052e06b984 Mon Sep 17 00:00:00 2001 From: Personal Date: Thu, 28 Sep 2023 07:47:54 -0400 Subject: [PATCH 57/75] Fix isort --- dbt/adapters/athena/lakeformation.py | 2 +- tests/unit/test_config.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dbt/adapters/athena/lakeformation.py b/dbt/adapters/athena/lakeformation.py index bae370cb..d0ffbc5d 100644 --- a/dbt/adapters/athena/lakeformation.py +++ b/dbt/adapters/athena/lakeformation.py @@ -13,8 +13,8 @@ ) from pydantic import BaseModel -from dbt.adapters.athena.relation import AthenaRelation from dbt.adapters.athena.constants import LOGGER +from dbt.adapters.athena.relation import AthenaRelation from dbt.exceptions import DbtRuntimeError diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 54775d10..00c374a6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,5 +1,6 @@ import importlib.metadata from unittest.mock import Mock + import pytest from dbt.adapters.athena.config import AthenaSparkSessionConfig, get_boto3_config From ea38eeef3d0e7d755803df5d4c60b3f9bf0ebdbf Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 09:54:53 -0400 Subject: [PATCH 58/75] Support additional arguments for create_table_as with dispatch. Call python code only from main call due to core restriction. --- .../macros/adapters/python_submissions.sql | 58 ++++++------- .../models/incremental/incremental.sql | 25 ++++++ .../models/table/create_table_as.sql | 86 ++++++++++--------- .../materializations/models/table/table.sql | 12 ++- 4 files changed, 104 insertions(+), 77 deletions(-) diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index bf389e4a..fbf1633b 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -16,33 +16,30 @@ import pyspark {{ compiled_code }} def materialize(spark_session, df, target_relation): import pandas - try: - if isinstance(df, pyspark.sql.dataframe.DataFrame): - pass - elif isinstance(df, pandas.core.frame.DataFrame): - df = spark_session.createDataFrame(df) - else: - msg = f"{type(df)} is not a supported type for dbt Python materialization" - raise Exception(msg) - writer = df.write \ - .format("{{ format }}") \ - .mode("{{ mode }}") \ - .option("path", "{{ location }}") \ - .option("compression", "{{ write_compression }}") \ - .option("mergeSchema", "{{ merge_schema }}") \ - .option("delimiter", "{{ field_delimiter }}") - if {{ partitioned_by }} is not None: - writer = writer.partitionBy({{ partitioned_by }}) - if {{ bucketed_by }} is not None: - writer = writer.bucketBy({{ bucket_count }},{{ bucketed_by }}) - if {{ sorted_by }} is not None: - writer = writer.sortBy({{ sorted_by }}) - writer.saveAsTable( - name="{{ target_relation.schema}}.{{ target_relation.identifier }}", - ) - return "OK" - except Exception: - raise + if isinstance(df, pyspark.sql.dataframe.DataFrame): + pass + elif isinstance(df, pandas.core.frame.DataFrame): + df = spark_session.createDataFrame(df) + else: + msg = f"{type(df)} is not a supported type for dbt Python materialization" + raise Exception(msg) + writer = df.write \ + .format("{{ format }}") \ + .mode("{{ mode }}") \ + .option("path", "{{ location }}") \ + .option("compression", "{{ write_compression }}") \ + .option("mergeSchema", "{{ merge_schema }}") \ + .option("delimiter", "{{ field_delimiter }}") + if {{ partitioned_by }} is not None: + writer = writer.partitionBy({{ partitioned_by }}) + if {{ bucketed_by }} is not None: + writer = writer.bucketBy({{ bucket_count }},{{ bucketed_by }}) + if {{ sorted_by }} is not None: + writer = writer.sortBy({{ sorted_by }}) + writer.saveAsTable( + name="{{ target_relation.schema}}.{{ target_relation.identifier }}", + ) + return "OK" {{ athena__py_get_spark_dbt_object() }} @@ -55,11 +52,8 @@ materialize(spark, df, dbt.this) {{ athena__py_get_spark_dbt_object() }} def execute_query(spark_session): - try: - spark_session.sql("""{{ query }}""") - return "OK" - except Exception: - raise + spark_session.sql("""{{ query }}""") + return "OK" dbt = SparkdbtObj() execute_query(spark) diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index f3c1d793..0d0335ad 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -26,10 +26,20 @@ {% set to_drop = [] %} {% if existing_relation is none %} {% set query_result = safe_create_table_as(False, target_relation, compiled_code, language=model_language) -%} + {%- if model_language == 'python' -%} + {% call statement('create_table', language=model_language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {% set build_sql = "select '" ~ query_result ~ "'" -%} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} {% set query_result = safe_create_table_as(False, target_relation, compiled_code, language=model_language) -%} + {%- if model_language == 'python' -%} + {% call statement('create_table', language=model_language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {% set build_sql = "select '" ~ query_result ~ "'" -%} {% elif partitioned_by is not none and strategy == 'insert_overwrite' %} {% set tmp_relation = make_temp_relation(target_relation) %} @@ -37,6 +47,11 @@ {% do drop_relation(tmp_relation) %} {% endif %} {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {%- if model_language == 'python' -%} + {% call statement('create_table', language=model_language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {% do delete_overlapping_partitions(target_relation, tmp_relation, partitioned_by) %} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} @@ -46,6 +61,11 @@ {% do drop_relation(tmp_relation) %} {% endif %} {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {%- if model_language == 'python' -%} + {% call statement('create_table', language=model_language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {% set build_sql = incremental_insert(on_schema_change, tmp_relation, target_relation, existing_relation) %} {% do to_drop.append(tmp_relation) %} {% elif strategy == 'merge' and table_type == 'iceberg' %} @@ -72,6 +92,11 @@ {% do drop_relation(tmp_relation) %} {% endif %} {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {%- if model_language == 'python' -%} + {% call statement('create_table', language=model_language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {% set build_sql = iceberg_merge( on_schema_change=on_schema_change, tmp_relation=tmp_relation, diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index d1b305e4..f5d0e4ce 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -1,6 +1,12 @@ -{% macro athena__create_table_as(temporary, relation, compiled_code, skip_partitioning=False, language='sql') -%} +{% macro create_table_as(temporary, relation, compiled_code, language='sql', skip_partitioning=false) -%} + {{ adapter.dispatch('create_table_as', 'athena')(temporary, relation, compiled_code, language, skip_partitioning) }} +{%- endmacro %} + + +{% macro athena__create_table_as(temporary, relation, compiled_code, language='sql', skip_partitioning=false) -%} {%- set materialized = config.get('materialized', default='table') -%} {%- set external_location = config.get('external_location', default=none) -%} + {%- do log("Skip partitioning: " ~ skip_partitioning) -%} {%- set partitioned_by = config.get('partitioned_by', default=none) if not skip_partitioning else none -%} {%- set bucketed_by = config.get('bucketed_by', default=none) -%} {%- set bucket_count = config.get('bucket_count', default=none) -%} @@ -53,19 +59,36 @@ {%- else -%} {% do adapter.delete_from_s3(location) %} {%- endif -%} - {%- if language == 'sql' -%} + {%- if language == 'python' -%} + {% do log('Creating table with spark and compiled code: ' ~ compiled_code) %} + {{ athena__py_save_table_as( + compiled_code, + relation, + optional_args={ + 'location': location, + 'format': format, + 'mode': 'overwrite', + 'partitioned_by': partitioned_by, + 'bucketed_by': bucketed_by, + 'write_compression': write_compression, + 'bucket_count': bucket_count, + 'field_delimiter': field_delimiter + } + ) + }} + {%- else -%} create table {{ relation }} with ( table_type='{{ table_type }}', is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, - {%- if not work_group_output_location_enforced or table_type == 'iceberg' -%} - {{ location_property }}='{{ location }}', - {%- endif %} + {%- if not work_group_output_location_enforced or table_type == 'iceberg' -%} + {{ location_property }}='{{ location }}', + {%- endif %} {%- if partitioned_by is not none %} - {{ partition_property }}=ARRAY{{ partitioned_by | join("', '") | replace('"', "'") | prepend("'") | append("'") }}, + {{ partition_property }}=ARRAY{{ partitioned_by | tojson | replace('\"', '\'') }}, {%- endif %} {%- if bucketed_by is not none %} - bucketed_by=ARRAY{{ bucketed_by | join("', '") | replace('"', "'") | prepend("'") | append("'") }}, + bucketed_by=ARRAY{{ bucketed_by | tojson | replace('\"', '\'') }}, {%- endif %} {%- if bucket_count is not none %} bucket_count={{ bucket_count }}, @@ -86,24 +109,6 @@ ) as {{ compiled_code }} - {%- elif language == 'python' -%} - {{ athena__py_save_table_as( - compiled_code, - relation, - optional_args={ - 'location': location, - 'format': format, - 'mode': 'overwrite', - 'partitioned_by': partitioned_by, - 'bucketed_by': bucketed_by, - 'write_compression': write_compression, - 'bucket_count': bucket_count, - 'field_delimiter': field_delimiter - } - ) - }} - {%- else -%} - {% do exceptions.raise_compiler_error("athena__create_table_as macro doesn't support the provided language, it got %s" % language) %} {%- endif -%} {%- endmacro -%} @@ -123,7 +128,7 @@ {%- endif -%} {%- do log('CREATE NON-PARTIONED STAGING TABLE: ' ~ tmp_relation) -%} - {%- do run_query(create_table_as(temporary, tmp_relation, compiled_code, True, language=language)) -%} + {%- do run_query(create_table_as(temporary, tmp_relation, compiled_code, language, true)) -%} {% set partitions_batches = get_partition_batches(sql=tmp_relation, as_subquery=False) %} {% do log('BATCHES TO PROCESS: ' ~ partitions_batches | length) %} @@ -140,7 +145,7 @@ from {{ tmp_relation }} where {{ batch }} {%- endset -%} - {%- do run_query(create_table_as(temporary, relation, create_target_relation_sql, language=language)) -%} + {%- do run_query(create_table_as(temporary, relation, create_target_relation_sql, language)) -%} {%- else -%} {%- set insert_batch_partitions_sql -%} insert into {{ relation }} ({{ dest_cols_csv }}) @@ -163,22 +168,19 @@ {% macro safe_create_table_as(temporary, relation, compiled_code, language='sql') -%} {%- if language != 'sql' -%} - {% call statement('py_save_table', language=language) -%} - {{ create_table_as(temporary, relation, compiled_code, language=language) }} - {%- endcall %} - {%- set compiled_code_result = relation ~ ' created with spark' -%} - {%- endif -%} - {%- if temporary -%} - {%- do run_query(create_table_as(temporary, relation, compiled_code, True, language=language)) -%} - {%- set compiled_code_result = relation ~ ' as temporary relation without partitioning created' -%} + {{ return(create_table_as(temporary, relation, compiled_code, language)) }} {%- else -%} - {%- set compiled_code_result = adapter.run_query_with_partitions_limit_catching(create_table_as(temporary, relation, compiled_code, language=language)) -%} - {%- do log('COMPILED CODE RESULT: ' ~ compiled_code_result) -%} - {%- if compiled_code_result == 'TOO_MANY_OPEN_PARTITIONS' -%} - {%- do create_table_as_with_partitions(temporary, relation, compiled_code, language=language) -%} - {%- set compiled_code_result = relation ~ ' with many partitions created' -%} - {%- endif -%} + {%- if temporary -%} + {%- do run_query(create_table_as(temporary, relation, compiled_code, language, true)) -%} + {%- set compiled_code_result = relation ~ ' as temporary relation without partitioning created' -%} + {%- else -%} + {%- set compiled_code_result = adapter.run_query_with_partitions_limit_catching(create_table_as(temporary, relation, compiled_code)) -%} + {%- do log('COMPILED CODE RESULT: ' ~ compiled_code_result) -%} + {%- if compiled_code_result == 'TOO_MANY_OPEN_PARTITIONS' -%} + {%- do create_table_as_with_partitions(temporary, relation, compiled_code, language) -%} + {%- set compiled_code_result = relation ~ ' with many partitions created' -%} + {%- endif -%} + {%- endif -%} {%- endif -%} - {{ return(compiled_code_result) }} {%- endmacro %} diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index 8471a33e..03e7beaa 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -68,7 +68,9 @@ {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language=language) -%} {%- endif -%} - {{ set_table_classification(target_relation) }} + {%- if language != 'python' -%} + {{ set_table_classification(target_relation) }} + {%- endif -%} {%- else -%} @@ -105,8 +107,12 @@ {%- endif -%} - {% call statement("main") %} - SELECT '{{ query_result }}'; + {% call statement("main", language=language) %} + {%- if language=='sql' -%} + SELECT '{{ query_result }}'; + {%- else -%} + {{ query_result }} + {%- endif -%} {% endcall %} {{ run_hooks(post_hooks) }} From 5060ae5c8cbad27ce67f912841b2076e96906512 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 10:55:27 -0400 Subject: [PATCH 59/75] Provide language as argument instead of kwarg. Reduce diff in connections aftr merge --- dbt/adapters/athena/connections.py | 7 +++---- .../models/incremental/incremental.sql | 10 +++++----- .../macros/materializations/models/table/table.sql | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index 1d46acb8..16d893f8 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -132,10 +132,9 @@ def __poll(self, query_id: str) -> AthenaQueryExecution: AthenaQueryExecution.STATE_CANCELLED, ]: return query_execution - else: - if self.connection.cursor_kwargs.get("debug_query_state", False): - LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") - time.sleep(self._poll_interval) + if self.connection.cursor_kwargs.get("debug_query_state", False): + LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") + time.sleep(self._poll_interval) def execute( # type: ignore self, diff --git a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql index 0d0335ad..4a057ba2 100644 --- a/dbt/include/athena/macros/materializations/models/incremental/incremental.sql +++ b/dbt/include/athena/macros/materializations/models/incremental/incremental.sql @@ -25,7 +25,7 @@ {% set to_drop = [] %} {% if existing_relation is none %} - {% set query_result = safe_create_table_as(False, target_relation, compiled_code, language=model_language) -%} + {% set query_result = safe_create_table_as(False, target_relation, compiled_code, model_language) -%} {%- if model_language == 'python' -%} {% call statement('create_table', language=model_language) %} {{ query_result }} @@ -34,7 +34,7 @@ {% set build_sql = "select '" ~ query_result ~ "'" -%} {% elif existing_relation.is_view or should_full_refresh() %} {% do drop_relation(existing_relation) %} - {% set query_result = safe_create_table_as(False, target_relation, compiled_code, language=model_language) -%} + {% set query_result = safe_create_table_as(False, target_relation, compiled_code, model_language) -%} {%- if model_language == 'python' -%} {% call statement('create_table', language=model_language) %} {{ query_result }} @@ -46,7 +46,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, model_language) -%} {%- if model_language == 'python' -%} {% call statement('create_table', language=model_language) %} {{ query_result }} @@ -60,7 +60,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, model_language) -%} {%- if model_language == 'python' -%} {% call statement('create_table', language=model_language) %} {{ query_result }} @@ -91,7 +91,7 @@ {% if tmp_relation is not none %} {% do drop_relation(tmp_relation) %} {% endif %} - {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, language=model_language) -%} + {% set query_result = safe_create_table_as(True, tmp_relation, compiled_code, model_language) -%} {%- if model_language == 'python' -%} {% call statement('create_table', language=model_language) %} {{ query_result }} diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index 03e7beaa..cda3822f 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -65,7 +65,7 @@ {%- if old_relation is not none -%} {{ drop_relation(old_relation) }} {%- endif -%} - {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language=language) -%} + {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language) -%} {%- endif -%} {%- if language != 'python' -%} @@ -75,10 +75,10 @@ {%- else -%} {%- if old_relation is none -%} - {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language=language) -%} + {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language) -%} {%- else -%} {%- if old_relation.is_view -%} - {%- set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language=language) -%} + {%- set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language) -%} {%- do drop_relation(old_relation) -%} {%- do rename_relation(tmp_relation, target_relation) -%} {%- else -%} @@ -96,7 +96,7 @@ {%- do drop_relation(old_relation_bkp) -%} {%- endif -%} - {% set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language=language) %} + {% set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language) %} {{ rename_relation(old_relation, old_relation_bkp) }} {{ rename_relation(tmp_relation, target_relation) }} From 88e8e441d59e96e62a921b3ef6cfe51f705d53f8 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:41:51 -0400 Subject: [PATCH 60/75] Restore diff in connections and fix functional test for constraint --- dbt/adapters/athena/connections.py | 7 ++++--- tests/functional/adapter/test_constraints.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index 16d893f8..1d46acb8 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -132,9 +132,10 @@ def __poll(self, query_id: str) -> AthenaQueryExecution: AthenaQueryExecution.STATE_CANCELLED, ]: return query_execution - if self.connection.cursor_kwargs.get("debug_query_state", False): - LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") - time.sleep(self._poll_interval) + else: + if self.connection.cursor_kwargs.get("debug_query_state", False): + LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") + time.sleep(self._poll_interval) def execute( # type: ignore self, diff --git a/tests/functional/adapter/test_constraints.py b/tests/functional/adapter/test_constraints.py index 9295f681..71a7d3a3 100644 --- a/tests/functional/adapter/test_constraints.py +++ b/tests/functional/adapter/test_constraints.py @@ -23,4 +23,4 @@ def expected_sql(self): # NOTE: by the above reason, this test just checks the query can be executed without errors. # The query itself is not checked. - return 'SELECT \'{"rowcount":1,"data_scanned_in_bytes":0}\';' + return 'SELECT \'{"rowcount":-1,"data_scanned_in_bytes":0}\';' From 855bd6ddeb50029096466766fd1dcef712fdec80 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:33:45 -0400 Subject: [PATCH 61/75] Restore test_constraint --- tests/functional/adapter/test_constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/adapter/test_constraints.py b/tests/functional/adapter/test_constraints.py index 71a7d3a3..9295f681 100644 --- a/tests/functional/adapter/test_constraints.py +++ b/tests/functional/adapter/test_constraints.py @@ -23,4 +23,4 @@ def expected_sql(self): # NOTE: by the above reason, this test just checks the query can be executed without errors. # The query itself is not checked. - return 'SELECT \'{"rowcount":-1,"data_scanned_in_bytes":0}\';' + return 'SELECT \'{"rowcount":1,"data_scanned_in_bytes":0}\';' From 8153d20ea329896287b1fe411c01d4f81d92620d Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:37:43 -0400 Subject: [PATCH 62/75] Disable python functional test until spark work group is added and IAM is modified --- .../adapter/test_python_submissions.py | 178 +++++++++--------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/tests/functional/adapter/test_python_submissions.py b/tests/functional/adapter/test_python_submissions.py index 4011bc97..671f587f 100644 --- a/tests/functional/adapter/test_python_submissions.py +++ b/tests/functional/adapter/test_python_submissions.py @@ -1,89 +1,89 @@ -import pytest -import yaml - -from dbt.tests.adapter.python_model.test_python_model import ( - BasePythonIncrementalTests, - BasePythonModelTests, -) -from dbt.tests.util import run_dbt - -basic_sql = """ -{{ config(materialized="table") }} -select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date -""" - -basic_python = """ -def model(dbt, spark): - dbt.config( - materialized='table', - ) - df = dbt.ref("model") - return df -""" - -basic_spark_python = """ -def model(dbt, spark_session): - dbt.config(materialized="table") - - data = [(1,), (2,), (3,), (4,)] - - df = spark_session.createDataFrame(data, ["A"]) - - return df -""" - -second_sql = """ -select * from {{ref('my_python_model')}} -""" - -schema_yml = """version: 2 -models: - - name: model - versions: - - v: 1 -""" - - -class TestBasePythonModelTests(BasePythonModelTests): - @pytest.fixture(scope="class") - def models(self): - return { - "schema.yml": schema_yml, - "model.sql": basic_sql, - "my_python_model.py": basic_python, - "spark_model.py": basic_spark_python, - "second_sql_model.sql": second_sql, - } - - -incremental_python = """ -def model(dbt, spark_session): - dbt.config(materialized="incremental") - df = dbt.ref("model") - - if dbt.is_incremental: - max_from_this = ( - f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" - ) - df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) - - return df -""" - - -class TestBasePythonIncrementalTests(BasePythonIncrementalTests): - @pytest.fixture(scope="class") - def project_config_update(self): - return {"models": {"+incremental_strategy": "append"}} - - @pytest.fixture(scope="class") - def models(self): - return {"model.sql": basic_sql, "incremental.py": incremental_python} - - def test_incremental(self, project): - vars_dict = { - "test_run_schema": project.test_schema, - } - - results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) - assert len(results) == 2 +# import pytest +# import yaml + +# from dbt.tests.adapter.python_model.test_python_model import ( +# BasePythonIncrementalTests, +# BasePythonModelTests, +# ) +# from dbt.tests.util import run_dbt + +# basic_sql = """ +# {{ config(materialized="table") }} +# select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date +# """ + +# basic_python = """ +# def model(dbt, spark): +# dbt.config( +# materialized='table', +# ) +# df = dbt.ref("model") +# return df +# """ + +# basic_spark_python = """ +# def model(dbt, spark_session): +# dbt.config(materialized="table") + +# data = [(1,), (2,), (3,), (4,)] + +# df = spark_session.createDataFrame(data, ["A"]) + +# return df +# """ + +# second_sql = """ +# select * from {{ref('my_python_model')}} +# """ + +# schema_yml = """version: 2 +# models: +# - name: model +# versions: +# - v: 1 +# """ + + +# class TestBasePythonModelTests(BasePythonModelTests): +# @pytest.fixture(scope="class") +# def models(self): +# return { +# "schema.yml": schema_yml, +# "model.sql": basic_sql, +# "my_python_model.py": basic_python, +# "spark_model.py": basic_spark_python, +# "second_sql_model.sql": second_sql, +# } + + +# incremental_python = """ +# def model(dbt, spark_session): +# dbt.config(materialized="incremental") +# df = dbt.ref("model") + +# if dbt.is_incremental: +# max_from_this = ( +# f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" +# ) +# df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) + +# return df +# """ + + +# class TestBasePythonIncrementalTests(BasePythonIncrementalTests): +# @pytest.fixture(scope="class") +# def project_config_update(self): +# return {"models": {"+incremental_strategy": "append"}} + +# @pytest.fixture(scope="class") +# def models(self): +# return {"model.sql": basic_sql, "incremental.py": incremental_python} + +# def test_incremental(self, project): +# vars_dict = { +# "test_run_schema": project.test_schema, +# } + +# results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) +# assert len(results) == 2 From 1effdbcd53a92abe6b1b2a7b1f6143ce2caf1c50 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Tue, 10 Oct 2023 13:39:49 -0400 Subject: [PATCH 63/75] Reduce diff in connections --- dbt/adapters/athena/connections.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index 1d46acb8..158a4adc 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -132,10 +132,10 @@ def __poll(self, query_id: str) -> AthenaQueryExecution: AthenaQueryExecution.STATE_CANCELLED, ]: return query_execution - else: - if self.connection.cursor_kwargs.get("debug_query_state", False): - LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") - time.sleep(self._poll_interval) + + if self.connection.cursor_kwargs.get("debug_query_state", False): + LOGGER.debug(f"Query state is: {query_execution.state}. Sleeping for {self._poll_interval}...") + time.sleep(self._poll_interval) def execute( # type: ignore self, From 9edb519c3a5c09061ade34467cdb862f2a9c828d Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Thu, 23 Nov 2023 08:57:51 -0500 Subject: [PATCH 64/75] Break engine config into three variables --- dbt/adapters/athena/config.py | 13 +++++++++++-- dbt/adapters/athena/constants.py | 4 +++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dbt/adapters/athena/config.py b/dbt/adapters/athena/config.py index b3d8f984..456afe3b 100644 --- a/dbt/adapters/athena/config.py +++ b/dbt/adapters/athena/config.py @@ -6,7 +6,9 @@ from dbt.adapters.athena.constants import ( DEFAULT_POLLING_INTERVAL, - DEFAULT_SPARK_ENGINE_CONFIG, + DEFAULT_SPARK_COORDINATOR_DPU_SIZE, + DEFAULT_SPARK_EXECUTOR_DPU_SIZE, + DEFAULT_SPARK_MAX_CONCURRENT_DPUS, DEFAULT_SPARK_SESSION_TIMEOUT, LOGGER, ) @@ -97,7 +99,14 @@ def set_engine_config(self) -> Dict[str, Any]: TypeError: If the engine configuration is not of type dict. KeyError: If the keys of the engine configuration dictionary do not match the expected format. """ - engine_config = self.config.get("engine_config", DEFAULT_SPARK_ENGINE_CONFIG) + engine_config = self.config.get( + "engine_config", + { + "CoordinatorDpuSize": DEFAULT_SPARK_COORDINATOR_DPU_SIZE, + "MaxConcurrentDpus": DEFAULT_SPARK_MAX_CONCURRENT_DPUS, + "DefaultExecutorDpuSize": DEFAULT_SPARK_EXECUTOR_DPU_SIZE, + }, + ) if not isinstance(engine_config, dict): raise TypeError("Engine configuration has to be of type dict") diff --git a/dbt/adapters/athena/constants.py b/dbt/adapters/athena/constants.py index b2d9b7e1..2b7e9ddd 100644 --- a/dbt/adapters/athena/constants.py +++ b/dbt/adapters/athena/constants.py @@ -3,7 +3,9 @@ DEFAULT_THREAD_COUNT = 4 DEFAULT_RETRY_ATTEMPTS = 3 DEFAULT_POLLING_INTERVAL = 5 -DEFAULT_SPARK_ENGINE_CONFIG = {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} +DEFAULT_SPARK_COORDINATOR_DPU_SIZE = 1 +DEFAULT_SPARK_MAX_CONCURRENT_DPUS = 2 +DEFAULT_SPARK_EXECUTOR_DPU_SIZE = 1 DEFAULT_SPARK_SESSION_TIMEOUT = 15 * 60 LOGGER = AdapterLogger(__name__) From eb2d92d4fface9c1346c33f7de5d44fb6e996214 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Thu, 23 Nov 2023 12:04:19 -0500 Subject: [PATCH 65/75] Add method to create session from credentials. Supply boto3 config to athena client --- dbt/adapters/athena/session.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 263b31ae..fa68cb00 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -8,6 +8,7 @@ import boto3 import boto3.session +from dbt.adapters.athena.config import get_boto3_config from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER from dbt.contracts.connection import Connection from dbt.exceptions import DbtRuntimeError @@ -25,6 +26,16 @@ def get_boto3_session(connection: Connection) -> boto3.session.Session: ) +def get_boto3_session_from_credentials(credentials: Any) -> boto3.session.Session: + return boto3.session.Session( + aws_access_key_id=credentials.aws_access_key_id, + aws_secret_access_key=credentials.aws_secret_access_key, + aws_session_token=credentials.aws_session_token, + region_name=credentials.region_name, + profile_name=credentials.aws_profile_name, + ) + + class AthenaSparkSessionManager: """ A helper class to manage Athena Spark Sessions. @@ -53,7 +64,7 @@ def spark_threads(self) -> int: Get the number of Spark threads. Returns: - Any: The number of Spark threads. If not found in the profile, returns the default thread count. + int: The number of Spark threads. If not found in the profile, returns the default thread count. """ if not self.credentials.spark_threads: LOGGER.debug( @@ -69,7 +80,7 @@ def spark_work_group(self) -> str: Get the Spark work group. Returns: - Any: The Spark work group. Raises an exception if not found in the profile. + str: The Spark work group. Raises an exception if not found in the profile. """ if not self.credentials.spark_work_group: raise DbtRuntimeError(f"Expected spark_work_group in profile. Got: {self.credentials.spark_work_group}") @@ -87,9 +98,9 @@ def athena_client(self) -> Any: Any: The Athena client object. """ - return boto3.session.Session( - region_name=self.credentials.region_name, profile_name=self.credentials.aws_profile_name - ).client("athena") + return get_boto3_session_from_credentials(self.credentials).client( + "athena", config=get_boto3_config(num_retries=self.credentials.effective_num_retries) + ) def get_new_sessions(self) -> List[UUID]: """ From 4dd9f94b04af6ec81d84af0b50f22c7e4247abda Mon Sep 17 00:00:00 2001 From: snagapuri Date: Sat, 30 Dec 2023 10:51:59 -0500 Subject: [PATCH 66/75] feat: enable iceberg table format for athena spark --- README.md | 58 ++++- dbt/adapters/athena/config.py | 66 ++++-- dbt/adapters/athena/constants.py | 32 ++- dbt/adapters/athena/python_submissions.py | 127 +++++++---- dbt/adapters/athena/session.py | 199 ++++++++---------- .../macros/adapters/python_submissions.sql | 16 +- .../models/table/create_table_as.sql | 79 ++++--- .../materializations/models/table/table.sql | 34 ++- dev-requirements.txt | 2 +- setup.py | 4 +- tests/unit/test_session.py | 8 +- 11 files changed, 412 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 0f051420..bf384267 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,6 @@ A dbt profile can be configured to run against AWS Athena using the following co | work_group | Identifier of Athena workgroup | Optional | `my-custom-workgroup` | | num_retries | Number of times to retry a failing query | Optional | `3` | | spark_work_group | Identifier of Athena Spark workgroup | Optional | `my-spark-workgroup` | -| spark_threads | Number of spark sessions to create. Recommended to be same as threads. | Optional | `4` | | num_boto3_retries | Number of times to retry boto3 requests (e.g. deleting S3 files for materialized tables) | Optional | `5` | | seed_s3_upload_args | Dictionary containing boto3 ExtraArgs when uploading to S3 | Optional | `{"ACL": "bucket-owner-full-control"}` | | lf_tags_database | Default LF tags for new database if it's created by dbt | Optional | `tag_key: tag_value` | @@ -157,7 +156,6 @@ athena: aws_profile_name: my-profile work_group: my-workgroup spark_work_group: my-spark-workgroup - spark_threads: 4 seed_s3_upload_args: ACL: bucket-owner-full-control ``` @@ -559,9 +557,28 @@ The adapter supports python models using [`spark`](https://docs.aws.amazon.com/a - A spark enabled work group created in athena - Spark execution role granted access to Athena, Glue and S3 - The spark work group is added to the ~/.dbt/profiles.yml file and the profile is referenced in dbt_project.yml -- The spark_threads value is added to ~/.dbt/profiles.yml which determines the maximum number of parallel spark sessions that will be created. It is recommended to keep this same as threads. + +### Spark specific table configuration + +- `timeout` (`default=43200`) + - Time out in seconds for each python model execution. Defaults to 12 hours/43200 seconds. +- `spark_encryption` (`default=false`) + - If this flag is set to true, encrypts data in transit between Spark nodes and also encrypts data at rest stored locally by Spark. +- `spark_cross_account_catalog` (`default=false`) + - In spark, you can query the external account catalog and for that the consumer account has to be configured to access the producer catalog. + - If this flag is set to true, "/" can be used as the glue catalog separator. Ex: 999999999999/mydatabase.cloudfront_logs (*where *999999999999* is the external catalog id*) +- `spark_requester_pays` (`default=false`) + - When an Amazon S3 bucket is configured as requester pays, the account of the user running the query is charged for data access and data transfer fees associated with the query. + - If this flag is set to true, requester pays S3 buckets are enabled in Athena for Spark. + +### Spark notes +- A session is created for each unique engine configuration defined in the models that are part of the invocation. +- A session's idle timeout is set to 10 minutes. Within the timeout period, if there is a new calculation (spark python model) ready for execution and the engine configuration matches, the process will reuse the same session. +- Number of python models running at a time depends on the `threads`. Number of sessions created for the entire run depends on number of unique engine configurations and availability of session to maintain threads concurrency. +- For iceberg table, it is recommended to use table_properties configuration to set the format_version to 2. This is to maintain compatability between iceberg tables created by Trino with those created by Spark. + ### Example models #### Simple pandas model @@ -617,6 +634,9 @@ def model(dbt, spark_session): "CoordinatorDpuSize": 1, "MaxConcurrentDpus": 3, "DefaultExecutorDpuSize": 1, + "spark_encryption": True, + "spark_cross_account_catalog": True, + "spark_requester_pays": True }, polling_interval=15, timeout=120, @@ -629,9 +649,39 @@ def model(dbt, spark_session): return df ``` +#### Create pySpark udf using imported external python files + +```python +def model(dbt, spark_session): + dbt.config( + materialized="incremental", + incremental_strategy="merge", + unique_key="num", + ) + sc = spark_session.sparkContext + sc.addPyFile("s3://athena-dbt/test/file1.py") + sc.addPyFile("s3://athena-dbt/test/file2.py") + + def func(iterator): + from file2 import transform + + return [transform(i) for i in iterator] + + from pyspark.sql.functions import udf + from pyspark.sql.functions import col + + udf_with_import = udf(func) + + data = [(1, "a"), (2, "b"), (3, "c")] + cols = ["num", "alpha"] + df = spark_session.createDataFrame(data, cols) + + return df.withColumn("udf_test_col", udf_with_import(col("alpha"))) +``` + #### Known issues in python models -- Incremental models do not fully utilize spark capabilities. They depend partially on existing sql based logic. +- Incremental models do not fully utilize spark capabilities. They depend partially on existing sql based logic which runs on trino. - Snapshots materializations are not supported. - Spark can only reference tables within the same catalog. diff --git a/dbt/adapters/athena/config.py b/dbt/adapters/athena/config.py index 456afe3b..fbdddc6b 100644 --- a/dbt/adapters/athena/config.py +++ b/dbt/adapters/athena/config.py @@ -9,7 +9,8 @@ DEFAULT_SPARK_COORDINATOR_DPU_SIZE, DEFAULT_SPARK_EXECUTOR_DPU_SIZE, DEFAULT_SPARK_MAX_CONCURRENT_DPUS, - DEFAULT_SPARK_SESSION_TIMEOUT, + DEFAULT_CALCULATION_TIMEOUT, + DEFAULT_SPARK_PROPERTIES, LOGGER, ) @@ -46,7 +47,7 @@ def set_timeout(self) -> int: ValueError: If the timeout value is not a positive integer. """ - timeout = self.config.get("timeout", DEFAULT_SPARK_SESSION_TIMEOUT) + timeout = self.config.get("timeout", DEFAULT_CALCULATION_TIMEOUT) if not isinstance(timeout, int): raise TypeError("Timeout must be an integer") if timeout <= 0: @@ -99,20 +100,59 @@ def set_engine_config(self) -> Dict[str, Any]: TypeError: If the engine configuration is not of type dict. KeyError: If the keys of the engine configuration dictionary do not match the expected format. """ - engine_config = self.config.get( - "engine_config", - { - "CoordinatorDpuSize": DEFAULT_SPARK_COORDINATOR_DPU_SIZE, - "MaxConcurrentDpus": DEFAULT_SPARK_MAX_CONCURRENT_DPUS, - "DefaultExecutorDpuSize": DEFAULT_SPARK_EXECUTOR_DPU_SIZE, - }, - ) + table_type = self.config.get("table_type", "hive") + spark_encryption = self.config.get("spark_encryption", False) + spark_cross_account_catalog = self.config.get("spark_cross_account_catalog", False) + spark_requester_pays = self.config.get("spark_requester_pays", False) + + default_spark_properties = {} + if table_type.lower() in ["iceberg", "hudi", "delta_lake"]: + default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get(table_type) + if spark_encryption: + default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_encryption") + if spark_cross_account_catalog: + default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_cross_account_catalog") + if spark_requester_pays: + default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_requester_pays") + + default_engine_config = { + "CoordinatorDpuSize": DEFAULT_SPARK_COORDINATOR_DPU_SIZE, + "MaxConcurrentDpus": DEFAULT_SPARK_MAX_CONCURRENT_DPUS, + "DefaultExecutorDpuSize": DEFAULT_SPARK_EXECUTOR_DPU_SIZE, + "SparkProperties": default_spark_properties, + } + engine_config = self.config.get("engine_config", None) + + if engine_config: + provided_spark_properties = engine_config.get("SparkProperties", None) + if provided_spark_properties: + default_spark_properties.update(provided_spark_properties) + default_engine_config["SparkProperties"] = default_spark_properties + engine_config.pop("SparkProperties") + default_engine_config.update(engine_config) + engine_config = default_engine_config + if not isinstance(engine_config, dict): raise TypeError("Engine configuration has to be of type dict") - expected_keys = {"CoordinatorDpuSize", "MaxConcurrentDpus", "DefaultExecutorDpuSize"} - if set(engine_config.keys()) != expected_keys: - raise KeyError(f"The keys of the dictionary entered do not match the expected format: {expected_keys}") + expected_keys = { + "CoordinatorDpuSize", + "MaxConcurrentDpus", + "DefaultExecutorDpuSize", + "SparkProperties", + "AdditionalConfigs", + } + + if set(engine_config.keys()) - { + "CoordinatorDpuSize", + "MaxConcurrentDpus", + "DefaultExecutorDpuSize", + "SparkProperties", + "AdditionalConfigs", + }: + raise KeyError( + f"The engine configuration keys provided do not match the expected athena engine keys: {expected_keys}" + ) if engine_config["MaxConcurrentDpus"] == 1: raise KeyError("The lowest value supported for MaxConcurrentDpus is 2") diff --git a/dbt/adapters/athena/constants.py b/dbt/adapters/athena/constants.py index 2b7e9ddd..aa1dd5e4 100644 --- a/dbt/adapters/athena/constants.py +++ b/dbt/adapters/athena/constants.py @@ -6,6 +6,36 @@ DEFAULT_SPARK_COORDINATOR_DPU_SIZE = 1 DEFAULT_SPARK_MAX_CONCURRENT_DPUS = 2 DEFAULT_SPARK_EXECUTOR_DPU_SIZE = 1 -DEFAULT_SPARK_SESSION_TIMEOUT = 15 * 60 +DEFAULT_CALCULATION_TIMEOUT = 43200 # seconds = 12 hours +SESSION_IDLE_TIMEOUT_MIN = 10 # minutes + +DEFAULT_SPARK_PROPERTIES = { + # https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark-table-formats.html + "iceberg": { + "spark.sql.catalog.spark_catalog": "org.apache.iceberg.spark.SparkSessionCatalog", + "spark.sql.catalog.spark_catalog.catalog-impl": "org.apache.iceberg.aws.glue.GlueCatalog", + "spark.sql.catalog.spark_catalog.io-impl": "org.apache.iceberg.aws.s3.S3FileIO", + "spark.sql.extensions": "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions", + }, + "hudi": { + "spark.sql.catalog.spark_catalog": "org.apache.spark.sql.hudi.catalog.HoodieCatalog", + "spark.serializer": "org.apache.spark.serializer.KryoSerializer", + "spark.sql.extensions": "org.apache.spark.sql.hudi.HoodieSparkSessionExtension", + }, + "delta_lake": { + "spark.sql.catalog.spark_catalog": "org.apache.spark.sql.delta.catalog.DeltaCatalog", + "spark.sql.extensions": "io.delta.sql.DeltaSparkSessionExtension", + }, + # https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark-encryption.html + "spark_encryption": { + "spark.authenticate": "true", + "spark.io.encryption.enabled": "true", + "spark.network.crypto.enabled": "true", + }, + # https://docs.aws.amazon.com/athena/latest/ug/spark-notebooks-cross-account-glue.html + "spark_cross_account_catalog": {"spark.hadoop.aws.glue.catalog.separator": "/"}, + # https://docs.aws.amazon.com/athena/latest/ug/notebooks-spark-requester-pays.html + "spark_requester_pays": {"spark.hadoop.fs.s3.useRequesterPaysHeader": "true"}, +} LOGGER = AdapterLogger(__name__) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 8acd2e6a..90b4e9b5 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -30,13 +30,14 @@ def __init__(self, parsed_model: Dict[Any, Any], credentials: AthenaCredentials) parsed_model (Dict[Any, Any]): The parsed python model. credentials (AthenaCredentials): Credentials for Athena connection. """ + self.relation_name = parsed_model.get("relation_name", None) self.config = AthenaSparkSessionConfig( parsed_model.get("config", {}), polling_interval=credentials.poll_interval, retry_attempts=credentials.num_retries, ) self.spark_connection = AthenaSparkSessionManager( - credentials, self.timeout, self.polling_interval, self.engine_config + credentials, self.timeout, self.polling_interval, self.engine_config, self.relation_name ) @cached_property @@ -98,25 +99,6 @@ def get_current_session_status(self) -> Any: """ return self.spark_connection.get_session_status(self.session_id) - def poll_until_session_idle(self) -> None: - """ - Polls the session status until it becomes idle or exceeds the timeout. - - Raises: - DbtRuntimeError: If the session chosen is not available or if it does not become idle within the timeout. - """ - polling_interval = self.polling_interval - while True: - session_status = self.get_current_session_status()["State"] - if session_status in ["FAILED", "TERMINATED", "DEGRADED"]: - raise DbtRuntimeError(f"The session chosen was not available. Got status: {session_status}") - if session_status == "IDLE": - break - time.sleep(polling_interval) - polling_interval *= 2 - if polling_interval > self.timeout: - raise DbtRuntimeError(f"Session {self.session_id} did not become free within {self.timeout} seconds.") - def submit(self, compiled_code: str) -> Any: """ Submit a calculation to Athena. @@ -139,6 +121,7 @@ def submit(self, compiled_code: str) -> Any: """ while True: try: + LOGGER.debug(f"Model {self.relation_name} - Using session: {self.session_id} to start calculation execution.") calculation_execution_id = self.athena_client.start_calculation_execution( SessionId=self.session_id, CodeBlock=compiled_code.lstrip() )["CalculationExecutionId"] @@ -153,27 +136,57 @@ def submit(self, compiled_code: str) -> Any: LOGGER.exception("Going to poll until session is IDLE") self.poll_until_session_idle() except Exception as e: - raise DbtRuntimeError(f"Unable to complete python execution. Got: {e}") + raise DbtRuntimeError(f"Unable to start spark python code execution. Got: {e}") execution_status = self.poll_until_execution_completion(calculation_execution_id) - LOGGER.debug(f"Received execution status {execution_status}") + LOGGER.debug(f"Model {self.relation_name} - Received execution status {execution_status}") if execution_status == "COMPLETED": try: result = self.athena_client.get_calculation_execution(CalculationExecutionId=calculation_execution_id)[ "Result" ] except Exception as e: - LOGGER.error(f"Unable to poll execution status: Got: {e}") + LOGGER.error(f"Unable to retrieve results: Got: {e}") result = {} - self.spark_connection.release_session_lock(self.session_id) return result + def poll_until_session_idle(self) -> None: + """ + Polls the session status until it becomes idle or exceeds the timeout. + + Raises: + DbtRuntimeError: If the session chosen is not available or if it does not become idle within the timeout. + """ + polling_interval = self.polling_interval + while True: + timer = 0 + session_status = self.get_current_session_status()["State"] + if session_status in ["TERMINATING", "TERMINATED", "DEGRADED", "FAILED"]: + LOGGER.debug( + f"Model {self.relation_name} - The session: {self.session_id} was not available. Got status: {session_status}. Will try with a different session." + ) + self.spark_connection.remove_terminated_session(self.session_id) + if "session_id" in self.__dict__: + del self.__dict__["session_id"] + break + if session_status == "IDLE": + break + time.sleep(polling_interval) + timer += polling_interval + if timer > self.timeout: + LOGGER.debug( + f"Model {self.relation_name} - Session {self.session_id} did not become free within {self.timeout} seconds. Will try with a different session." + ) + if "session_id" in self.__dict__: + del self.__dict__["session_id"] + break + def poll_until_execution_completion(self, calculation_execution_id: str) -> Any: """ - Poll the status of a calculation execution until it is completed, failed, or cancelled. + Poll the status of a calculation execution until it is completed, failed, or canceled. This function polls the status of a calculation execution identified by the given `calculation_execution_id` - until it is completed, failed, or cancelled. It uses the Athena client to retrieve the status of the execution - and checks if the state is one of "COMPLETED", "FAILED", or "CANCELLED". If the execution is not yet completed, + until it is completed, failed, or canceled. It uses the Athena client to retrieve the status of the execution + and checks if the state is one of "COMPLETED", "FAILED", or "CANCELED". If the execution is not yet completed, the function sleeps for a certain polling interval, which starts with the value of `self.polling_interval` and doubles after each iteration until it reaches the `self.timeout` period. If the execution does not complete within the timeout period, a `DbtRuntimeError` is raised. @@ -182,27 +195,51 @@ def poll_until_execution_completion(self, calculation_execution_id: str) -> Any: calculation_execution_id (str): The ID of the calculation execution to poll. Returns: - str: The final state of the calculation execution, which can be one of "COMPLETED", "FAILED" or "CANCELLED". + str: The final state of the calculation execution, which can be one of "COMPLETED", "FAILED" or "CANCELED". Raises: DbtRuntimeError: If the calculation execution does not complete within the timeout period. """ - polling_interval = self.polling_interval - while True: - execution_status = self.athena_client.get_calculation_execution_status( - CalculationExecutionId=calculation_execution_id - )["Status"]["State"] - if execution_status in ["FAILED", "CANCELLED"]: - raise DbtRuntimeError( - f"""Execution {calculation_execution_id} did not complete successfully. - Got: {execution_status} status.""" - ) - if execution_status == "COMPLETED": - return execution_status - time.sleep(polling_interval) - polling_interval *= 2 - if polling_interval > self.timeout: - raise DbtRuntimeError( - f"Execution {calculation_execution_id} did not complete within {self.timeout} seconds." + try: + polling_interval = self.polling_interval + while True: + timer = 0 + execution_response = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id ) + execution_session = execution_response.get("SessionId", None) + execution_status = execution_response.get("Status", None) + execution_result = execution_response.get("Result", None) + execution_stderr_s3_path = "" + if execution_result: + execution_stderr_s3_path = execution_result.get("StdErrorS3Uri", None) + + execution_status_state = "" + execution_status_reason = "" + if execution_status: + execution_status_state = execution_status.get("State", None) + execution_status_reason = execution_status.get("StateChangeReason", None) + + if execution_status_state in ["FAILED", "CANCELED"]: + raise DbtRuntimeError( + f"""Calculation Id: {calculation_execution_id} +Session Id: {execution_session} +Status: {execution_status_state} +Reason: {execution_status_reason} +Stderr s3 path: {execution_stderr_s3_path} +""" + ) + + if execution_status_state == "COMPLETED": + return execution_status_state + + time.sleep(polling_interval) + timer += polling_interval + if timer > self.timeout: + self.athena_client.stop_calculation_execution(CalculationExecutionId=calculation_execution_id) + raise DbtRuntimeError( + f"Execution {calculation_execution_id} did not complete within {self.timeout} seconds." + ) + finally: + self.spark_connection.set_spark_session_load(self.session_id, -1) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index fa68cb00..dd9fbf23 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -2,18 +2,22 @@ import time from datetime import datetime, timedelta, timezone from functools import cached_property +from hashlib import md5 from typing import Any, Dict, List from uuid import UUID - import boto3 import boto3.session +import json from dbt.adapters.athena.config import get_boto3_config -from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER +from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER, SESSION_IDLE_TIMEOUT_MIN from dbt.contracts.connection import Connection +from dbt.events.functions import get_invocation_id from dbt.exceptions import DbtRuntimeError -spark_session_locks: Dict[UUID, threading.Lock] = {} +invocation_id = get_invocation_id() +spark_session_list: Dict[UUID, str] = {} +spark_session_load: Dict[UUID, int] = {} def get_boto3_session(connection: Connection) -> boto3.session.Session: @@ -41,7 +45,7 @@ class AthenaSparkSessionManager: A helper class to manage Athena Spark Sessions. """ - def __init__(self, credentials: Any, timeout: int, polling_interval: float, engine_config: Dict[str, int]) -> None: + def __init__(self, credentials: Any, timeout: int, polling_interval: float, engine_config: Dict[str, int], relation_name: str = None) -> None: """ Initialize the AthenaSparkSessionManager instance. @@ -57,6 +61,7 @@ def __init__(self, credentials: Any, timeout: int, polling_interval: float, engi self.polling_interval = polling_interval self.engine_config = engine_config self.lock = threading.Lock() + self.relation_name = relation_name @cached_property def spark_threads(self) -> int: @@ -102,99 +107,56 @@ def athena_client(self) -> Any: "athena", config=get_boto3_config(num_retries=self.credentials.effective_num_retries) ) - def get_new_sessions(self) -> List[UUID]: - """ - Retrieves a list of new sessions by subtracting the existing sessions from the complete session list. - If no new sessions are found a new session is created provided the number of sessions is less than the - number of allowed spark threads. - - Returns: - List[UUID]: A list of new session UUIDs. - - """ - sessions = self.list_sessions() - existing_sessions = set(spark_session_locks.keys()) - new_sessions = [session for session in sessions if session not in existing_sessions] - - if len(new_sessions) == 0: - if len(spark_session_locks) < self.spark_threads: - return [self.start_session()] - LOGGER.warning( - f"""Maximum spark session count: {self.spark_threads} reached. - Cannot start new spark session.""" - ) - LOGGER.debug(f"Setting sessions: {new_sessions}") - return new_sessions - - def update_spark_session_locks(self) -> None: + @cached_property + def session_description(self) -> str: """ - Update session locks for each session. - - This function iterates over the existing sessions and ensures that a session lock is created for each session. - If a session lock already exists, it is left unchanged. - - Args: - self: The instance of the class. + Converts the engine configuration to md5 hash value Returns: - None + str: A concatenated text of dbt invocation_id and engine configuration's md5 hash """ - for session_uuid in self.get_new_sessions(): - spark_session_locks.setdefault(session_uuid, threading.Lock()) - LOGGER.debug(f"Updated session locks: {spark_session_locks}") + hash_desc = md5(json.dumps(self.engine_config, sort_keys=True, ensure_ascii=True).encode("utf-8")).hexdigest() + return f"dbt: {invocation_id} - {hash_desc}" - def get_session_id(self) -> UUID: + def get_session_id(self, session_query_capacity: int = 1) -> UUID: """ Get a session ID for the Spark session. + When does a new session get created: + - When thread limit not reached + - When thread limit reached but same engine configuration session is not available + - When thread limit reached and same engine configuration session exist and it is busy running a python model and has one python model in queue (determined by session_query_capacity). Returns: UUID: The session ID. - - Notes: - This method acquires a lock on an existing available Spark session. If no session is available, - it waits for a certain period and retries until a session becomes available or a timeout is reached. - The session ID of the acquired session is returned. - """ - polling_interval = self.polling_interval - self.update_spark_session_locks() - while True: - for session_uuid, lock in spark_session_locks.items(): - if not lock.locked(): - LOGGER.debug(f"Locking existing session: {session_uuid}") - lock.acquire(blocking=False) - return session_uuid + session_list = list(spark_session_list.items()) + + if len(session_list) < self.spark_threads: LOGGER.debug( - f"""All available spark sessions: {spark_session_locks.keys()} are locked. - Going to sleep: {polling_interval} seconds.""" + f"Within thread limit, creating new session for model: {self.relation_name} with session description: {self.session_description}." ) - time.sleep(polling_interval) - polling_interval *= 2 - - def list_sessions(self, state: str = "IDLE") -> List[UUID]: - """ - List the sessions based on the specified state. - - Args: - state (str, optional): The state to filter the sessions. Defaults to "IDLE". - - Returns: - List[UUID]: A list of session IDs matching the specified state. - - Notes: - This method utilizes the Athena client to list sessions in the Spark work group. - The sessions are filtered based on the provided state. - If no sessions are found or the response does not contain any sessions, an empty list is returned. - - """ - response = self.athena_client.list_sessions( - WorkGroup=self.spark_work_group, - MaxResults=self.spark_threads, - StateFilter=state, - ) - if response.get("Sessions") is None: - return [] - return [UUID(session_string["SessionId"]) for session_string in response.get("Sessions")] + return self.start_session() + else: + matching_session_id = next( + ( + session_id + for session_id, description in session_list + if description == self.session_description + and spark_session_load.get(session_id, 0) <= session_query_capacity + ), + None, + ) + if matching_session_id: + LOGGER.debug( + f"Over thread limit, matching session found for model: {self.relation_name} with session description: {self.session_description} and has capacity." + ) + self.set_spark_session_load(str(matching_session_id), 1) + return matching_session_id + else: + LOGGER.debug( + f"Over thread limit, matching session not found or found with over capacity. Creating new session for model: {self.relation_name} with session description: {self.session_description}." + ) + return self.start_session() def start_session(self) -> UUID: """ @@ -208,13 +170,22 @@ def start_session(self) -> UUID: dict: The session information dictionary. """ + description = self.session_description response = self.athena_client.start_session( + Description=description, WorkGroup=self.credentials.spark_work_group, EngineConfiguration=self.engine_config, + SessionIdleTimeoutInMinutes=SESSION_IDLE_TIMEOUT_MIN, ) + session_id = response["SessionId"] if response["State"] != "IDLE": - self.poll_until_session_creation(response["SessionId"]) - return UUID(response["SessionId"]) + self.poll_until_session_creation(session_id) + + with self.lock: + spark_session_list[UUID(session_id)] = self.session_description + spark_session_load[UUID(session_id)] = 1 + + return UUID(session_id) def poll_until_session_creation(self, session_id: str) -> None: """ @@ -233,45 +204,47 @@ def poll_until_session_creation(self, session_id: str) -> None: """ polling_interval = self.polling_interval while True: - creation_status = self.get_session_status(session_id)["State"] - if creation_status in ["FAILED", "TERMINATED", "DEGRADED"]: - raise DbtRuntimeError(f"Unable to create session: {session_id}. Got status: {creation_status}.") - elif creation_status == "IDLE": + timer = 0 + creation_status_response = self.get_session_status(session_id) + creation_status_state = creation_status_response.get("State", "") + creation_status_reason = creation_status_response.get("StateChangeReason", "") + if creation_status_state in ["FAILED", "TERMINATED", "DEGRADED"]: + raise DbtRuntimeError( + f"Unable to create session: {session_id}. Got status: {creation_status_state} with reason: {creation_status_reason}." + ) + elif creation_status_state == "IDLE": LOGGER.debug(f"Session: {session_id} created") break time.sleep(polling_interval) - polling_interval *= 2 - if polling_interval > self.timeout: + timer += polling_interval + if timer > self.timeout: + self.remove_terminated_session(session_id) raise DbtRuntimeError(f"Session {session_id} did not create within {self.timeout} seconds.") - def release_session_lock(self, session_id: str) -> None: + def get_session_status(self, session_id: str) -> Any: """ - Terminate the current Athena session. - - This function terminates the current Athena session if it is in IDLE or BUSY state and has exceeded the - configured timeout period. It retrieves the session status, and if the session state is IDLE or BUSY and the - duration since the session start time exceeds the timeout period, the session is terminated. The session ID is - used to terminate the session via the Athena client. + Get the session status. Returns: - None + Any: The status of the session + """ + return self.athena_client.get_session_status(SessionId=session_id)["Status"] + def remove_terminated_session(self, session_id: str) -> None: + """ + Removes session uuid from session list variable + + Returns: None """ - session_status = self.get_session_status(session_id) - if session_status["State"] in ["IDLE", "BUSY"] and ( - session_status["StartDateTime"] - datetime.now(tz=timezone.utc) > timedelta(seconds=self.timeout) - ): - LOGGER.debug(f"Terminating session: {session_id}") - self.athena_client.terminate_session(SessionId=session_id) with self.lock: - LOGGER.debug(f"Releasing lock for session: {session_id}") - spark_session_locks[UUID(session_id)].release() + spark_session_list.pop(UUID(session_id), "Session id not found") + spark_session_load.pop(UUID(session_id), "Session id not found") - def get_session_status(self, session_id: str) -> Any: + def set_spark_session_load(self, session_id: str, change: int) -> None: """ - Get the session status. + Increase or decrease the session load variable - Returns: - Any: The status of the session + Returns: None """ - return self.athena_client.get_session_status(SessionId=session_id)["Status"] + with self.lock: + spark_session_load[UUID(session_id)] = spark_session_load.get(UUID(session_id), 0) + change diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index fbf1633b..137b117c 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -9,7 +9,8 @@ {% set merge_schema = optional_args.get("merge_schema", true) %} {% set bucket_count = optional_args.get("bucket_count") %} {% set field_delimiter = optional_args.get("field_delimiter") %} - + {% set spark_ctas = optional_args.get("spark_ctas", "") %} + import pyspark @@ -23,6 +24,14 @@ def materialize(spark_session, df, target_relation): else: msg = f"{type(df)} is not a supported type for dbt Python materialization" raise Exception(msg) + +{% if spark_ctas|length > 0 %} + df.createOrReplaceTempView("{{ target_relation.schema}}_{{ target_relation.identifier }}_tmpvw") + spark_session.sql(""" + {{ spark_ctas }} + select * from {{ target_relation.schema}}_{{ target_relation.identifier }}_tmpvw + """) +{% else %} writer = df.write \ .format("{{ format }}") \ .mode("{{ mode }}") \ @@ -36,10 +45,13 @@ def materialize(spark_session, df, target_relation): writer = writer.bucketBy({{ bucket_count }},{{ bucketed_by }}) if {{ sorted_by }} is not None: writer = writer.sortBy({{ sorted_by }}) + writer.saveAsTable( name="{{ target_relation.schema}}.{{ target_relation.identifier }}", ) - return "OK" +{% endif %} + + return "Success: {{ target_relation.schema}}.{{ target_relation.identifier }}" {{ athena__py_get_spark_dbt_object() }} diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 1541b234..5b9b9b94 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -36,34 +36,43 @@ {{ get_assert_columns_equivalent(compiled_code) }} {%- endif -%} - {%- if table_type == 'iceberg' -%} - {%- set location_property = 'location' -%} - {%- set partition_property = 'partitioning' -%} - {%- if bucketed_by is not none or bucket_count is not none -%} - {%- set ignored_bucket_iceberg -%} - bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function - when partitioning. Will be ignored - {%- endset -%} - {%- set bucketed_by = none -%} - {%- set bucket_count = none -%} - {% do log(ignored_bucket_iceberg) %} - {%- endif -%} - {%- if 'unique' not in s3_data_naming or external_location is not none -%} - {%- set error_unique_location_iceberg -%} - You need to have an unique table location when creating Iceberg table since we use the RENAME feature - to have near-zero downtime. - {%- endset -%} - {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} - {%- endif -%} - {%- endif %} - {%- if native_drop and table_type == 'iceberg' -%} {% do log('Config native_drop enabled, skipping direct S3 delete') %} {%- else -%} {% do adapter.delete_from_s3(location) %} {%- endif -%} + {%- if language == 'python' -%} - {% do log('Creating table with spark and compiled code: ' ~ compiled_code) %} + {%- set spark_ctas = '' -%} + {%- if table_type == 'iceberg' -%} + {%- set spark_ctas -%} + create table {{ relation.schema | replace('\"', '`') }}.{{ relation.identifier | replace('\"', '`') }} + using iceberg + location '{{ location }}/' + + {%- if partitioned_by is not none %} + partitioned by ( + {%- for prop_value in partitioned_by -%} + {{ prop_value }} + {%- if not loop.last %},{% endif -%} + {%- endfor -%} + ) + {%- endif %} + + {%- if extra_table_properties is not none %} + tblproperties( + {%- for prop_name, prop_value in extra_table_properties.items() -%} + '{{ prop_name }}'='{{ prop_value }}' + {%- if not loop.last %},{% endif -%} + {%- endfor -%} + ) + {% endif %} + + as + {%- endset -%} + {%- endif -%} + + {# {% do log('Creating table with spark and compiled code: ' ~ compiled_code) %} #} {{ athena__py_save_table_as( compiled_code, relation, @@ -75,12 +84,34 @@ 'bucketed_by': bucketed_by, 'write_compression': write_compression, 'bucket_count': bucket_count, - 'field_delimiter': field_delimiter + 'field_delimiter': field_delimiter, + 'spark_ctas': spark_ctas } ) }} {%- else -%} - create table {{ relation }} + {%- if table_type == 'iceberg' -%} + {%- set location_property = 'location' -%} + {%- set partition_property = 'partitioning' -%} + {%- if bucketed_by is not none or bucket_count is not none -%} + {%- set ignored_bucket_iceberg -%} + bucketed_by or bucket_count cannot be used with Iceberg tables. You have to use the bucket function + when partitioning. Will be ignored + {%- endset -%} + {%- set bucketed_by = none -%} + {%- set bucket_count = none -%} + {% do log(ignored_bucket_iceberg) %} + {%- endif -%} + {%- if 'unique' not in s3_data_naming or external_location is not none -%} + {%- set error_unique_location_iceberg -%} + You need to have an unique table location when creating Iceberg table since we use the RENAME feature + to have near-zero downtime. + {%- endset -%} + {% do exceptions.raise_compiler_error(error_unique_location_iceberg) %} + {%- endif -%} + {%- endif %} + + create table {{ relation }} with ( table_type='{{ table_type }}', is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, diff --git a/dbt/include/athena/macros/materializations/models/table/table.sql b/dbt/include/athena/macros/materializations/models/table/table.sql index 2f727d0a..214fe5ae 100644 --- a/dbt/include/athena/macros/materializations/models/table/table.sql +++ b/dbt/include/athena/macros/materializations/models/table/table.sql @@ -52,7 +52,12 @@ -- create tmp table {%- set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language, force_batch) -%} - + -- Execute python code that is available in query result object + {%- if language == 'python' -%} + {% call statement('create_table', language=language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} -- swap table {%- set swap_table = adapter.swap_table(tmp_relation, target_relation) -%} @@ -67,6 +72,12 @@ {{ drop_relation(old_relation) }} {%- endif -%} {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language, force_batch) -%} + -- Execute python code that is available in query result object + {%- if language == 'python' -%} + {% call statement('create_table', language=language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {%- endif -%} {%- if language != 'python' -%} @@ -77,9 +88,21 @@ {%- if old_relation is none -%} {%- set query_result = safe_create_table_as(False, target_relation, compiled_code, language, force_batch) -%} + -- Execute python code that is available in query result object + {%- if language == 'python' -%} + {% call statement('create_table', language=language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {%- else -%} {%- if old_relation.is_view -%} {%- set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language, force_batch) -%} + -- Execute python code that is available in query result object + {%- if language == 'python' -%} + {% call statement('create_table', language=language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {%- do drop_relation(old_relation) -%} {%- do rename_relation(tmp_relation, target_relation) -%} {%- else -%} @@ -98,7 +121,12 @@ {%- endif -%} {% set query_result = safe_create_table_as(False, tmp_relation, compiled_code, language, force_batch) %} - + -- Execute python code that is available in query result object + {%- if language == 'python' -%} + {% call statement('create_table', language=language) %} + {{ query_result }} + {% endcall %} + {%- endif -%} {{ rename_relation(old_relation, old_relation_bkp) }} {{ rename_relation(tmp_relation, target_relation) }} @@ -111,8 +139,6 @@ {% call statement("main", language=language) %} {%- if language=='sql' -%} SELECT '{{ query_result }}'; - {%- else -%} - {{ query_result }} {%- endif -%} {% endcall %} diff --git a/dev-requirements.txt b/dev-requirements.txt index 5ff9f57b..c6c3bdb1 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ autoflake~=1.7 black~=23.11 -boto3-stubs[s3]~=1.29 +boto3-stubs[s3]~=1.34 dbt-tests-adapter~=1.7.2 flake8~=6.1 Flake8-pyproject~=1.2 diff --git a/setup.py b/setup.py index a2509409..1831e6d4 100644 --- a/setup.py +++ b/setup.py @@ -53,8 +53,8 @@ def _get_package_version() -> str: include_package_data=True, install_requires=[ # In order to control dbt-core version and package version - "boto3~=1.26", - "boto3-stubs[athena,glue,lakeformation,sts]~=1.26", + "boto3~=1.34", + "boto3-stubs[athena,glue,lakeformation,sts]~=1.34", "dbt-core~=1.7.0", "pyathena>=2.25,<4.0", "pydantic>=1.10,<3.0", diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 49d910c8..539b6f25 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -314,8 +314,8 @@ def test_update_spark_session_locks( ): spark_session_manager.update_spark_session_locks() for each_session in get_session_response: - assert each_session in session.spark_session_locks.keys() - assert type(session.spark_session_locks[each_session]) is not None + assert each_session in session.spark_session_list.keys() + assert type(session.spark_session_list[each_session]) is not None def test_get_session_id(self): pass @@ -376,5 +376,5 @@ def test_release_session_lock( terminate_session=Mock(return_value=terminate_session_response), ): spark_session_manager.release_session_lock(test_session_id) - assert UUID(test_session_id) in session.spark_session_locks.keys() - assert type(session.spark_session_locks[UUID(test_session_id)]) is not None + assert UUID(test_session_id) in session.spark_session_list.keys() + assert type(session.spark_session_list[UUID(test_session_id)]) is not None From 200a8fe133f103733d6be9a6614432cf52a37174 Mon Sep 17 00:00:00 2001 From: snagapuri Date: Tue, 2 Jan 2024 23:50:30 -0500 Subject: [PATCH 67/75] fix: empty code submissions I am seeing an empty calculation along with main python model code calculation is submitted for almost every model Also, if not returning the result json, we are getting green ERROR messages instead of OK messages. And with this handling, I am not seeing the run model code in target folder every model under run folder seems to be empty. Need to address this work around solution in order to have the target folder show the run model content. --- dbt/adapters/athena/python_submissions.py | 67 +++++++++++++---------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index 90b4e9b5..ffc2845d 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -119,35 +119,44 @@ def submit(self, compiled_code: str) -> Any: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ - while True: - try: - LOGGER.debug(f"Model {self.relation_name} - Using session: {self.session_id} to start calculation execution.") - calculation_execution_id = self.athena_client.start_calculation_execution( - SessionId=self.session_id, CodeBlock=compiled_code.lstrip() - )["CalculationExecutionId"] - break - except botocore.exceptions.ClientError as ce: - LOGGER.exception(f"Encountered client error: {ce}") - if ( - ce.response["Error"]["Code"] == "InvalidRequestException" - and "Session is in the BUSY state; needs to be IDLE to accept Calculations." - in ce.response["Error"]["Message"] - ): - LOGGER.exception("Going to poll until session is IDLE") - self.poll_until_session_idle() - except Exception as e: - raise DbtRuntimeError(f"Unable to start spark python code execution. Got: {e}") - execution_status = self.poll_until_execution_completion(calculation_execution_id) - LOGGER.debug(f"Model {self.relation_name} - Received execution status {execution_status}") - if execution_status == "COMPLETED": - try: - result = self.athena_client.get_calculation_execution(CalculationExecutionId=calculation_execution_id)[ - "Result" - ] - except Exception as e: - LOGGER.error(f"Unable to retrieve results: Got: {e}") - result = {} - return result + # I am seeing an empty calculation along with main python model code calculation is submitted for almost every model + # Also, if not returning the result json, we are getting green ERROR messages instead of OK messages. + # And with this handling, I am not seeing the run model code in target folder every model under run folder seems to be empty. + # Need to fix this work around solution + if compiled_code.strip(): + while True: + try: + LOGGER.debug( + f"Model {self.relation_name} - Using session: {self.session_id} to start calculation execution." + ) + calculation_execution_id = self.athena_client.start_calculation_execution( + SessionId=self.session_id, CodeBlock=compiled_code.lstrip() + )["CalculationExecutionId"] + break + except botocore.exceptions.ClientError as ce: + LOGGER.exception(f"Encountered client error: {ce}") + if ( + ce.response["Error"]["Code"] == "InvalidRequestException" + and "Session is in the BUSY state; needs to be IDLE to accept Calculations." + in ce.response["Error"]["Message"] + ): + LOGGER.exception("Going to poll until session is IDLE") + self.poll_until_session_idle() + except Exception as e: + raise DbtRuntimeError(f"Unable to start spark python code execution. Got: {e}") + execution_status = self.poll_until_execution_completion(calculation_execution_id) + LOGGER.debug(f"Model {self.relation_name} - Received execution status {execution_status}") + if execution_status == "COMPLETED": + try: + result = self.athena_client.get_calculation_execution( + CalculationExecutionId=calculation_execution_id + )["Result"] + except Exception as e: + LOGGER.error(f"Unable to retrieve results: Got: {e}") + result = {} + return result + else: + return {"ResultS3Uri": "string", "ResultType": "string", "StdErrorS3Uri": "string", "StdOutS3Uri": "string"} def poll_until_session_idle(self) -> None: """ From 89d2d92f62b11350ef1a52d44732991dc92b6bd0 Mon Sep 17 00:00:00 2001 From: snagapuri Date: Tue, 9 Jan 2024 22:07:14 -0500 Subject: [PATCH 68/75] fix: remove references to obsolete config - spark_threads --- .env.example | 1 - dbt/adapters/athena/connections.py | 2 - dbt/adapters/athena/session.py | 9 +- tests/conftest.py | 3 - .../adapter/test_python_submissions.py | 178 +++++++++--------- tests/unit/constants.py | 3 +- 6 files changed, 94 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index 20a4ace8..7182e9c0 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,3 @@ DBT_TEST_ATHENA_SCHEMA= DBT_TEST_ATHENA_WORK_GROUP= DBT_TEST_ATHENA_AWS_PROFILE_NAME= DBT_TEST_ATHENA_SPARK_WORK_GROUP= -DBT_TEST_ATHENA_SPARK_THREADs= diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index ab272bcf..259dad7a 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -60,7 +60,6 @@ class AthenaCredentials(Credentials): s3_data_dir: Optional[str] = None s3_data_naming: Optional[str] = "schema_table_unique" spark_work_group: Optional[str] = None - spark_threads: Optional[int] = DEFAULT_THREAD_COUNT s3_tmp_table_dir: Optional[str] = None # Unfortunately we can not just use dict, must be Dict because we'll get the following error: # Credentials in profile "athena", target "athena" invalid: Unable to create schema for 'dict' @@ -99,7 +98,6 @@ def _connection_keys(self) -> Tuple[str, ...]: "seed_s3_upload_args", "lf_tags_database", "spark_work_group", - "spark_threads", ) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index dd9fbf23..0cfdfd1f 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -71,13 +71,12 @@ def spark_threads(self) -> int: Returns: int: The number of Spark threads. If not found in the profile, returns the default thread count. """ - if not self.credentials.spark_threads: + if not DEFAULT_THREAD_COUNT: LOGGER.debug( - f"""Spark threads not found in profile. Got: {self.credentials.spark_threads}. - Using default count: {DEFAULT_THREAD_COUNT}""" + f"""Threads not found in profile. Got: {DEFAULT_THREAD_COUNT}""" ) - return DEFAULT_THREAD_COUNT - return int(self.credentials.spark_threads) + return 1 + return int(DEFAULT_THREAD_COUNT) @cached_property def spark_work_group(self) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index ae37166c..3701479c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ DATA_CATALOG_NAME, DATABASE_NAME, S3_STAGING_DIR, - SPARK_THREADS, SPARK_WORKGROUP, ) @@ -44,7 +43,6 @@ def dbt_profile_target(): "work_group": os.getenv("DBT_TEST_ATHENA_WORK_GROUP"), "aws_profile_name": os.getenv("DBT_TEST_ATHENA_AWS_PROFILE_NAME") or None, "spark_work_group": os.getenv("DBT_TEST_ATHENA_SPARK_WORK_GROUP"), - "spark_threads": os.getenv("DBT_TEST_ATHENA_SPARK_THREADS"), } @@ -89,5 +87,4 @@ def athena_credentials(): region_name=AWS_REGION, work_group=ATHENA_WORKGROUP, spark_work_group=SPARK_WORKGROUP, - spark_threads=SPARK_THREADS, ) diff --git a/tests/functional/adapter/test_python_submissions.py b/tests/functional/adapter/test_python_submissions.py index 671f587f..4011bc97 100644 --- a/tests/functional/adapter/test_python_submissions.py +++ b/tests/functional/adapter/test_python_submissions.py @@ -1,89 +1,89 @@ -# import pytest -# import yaml - -# from dbt.tests.adapter.python_model.test_python_model import ( -# BasePythonIncrementalTests, -# BasePythonModelTests, -# ) -# from dbt.tests.util import run_dbt - -# basic_sql = """ -# {{ config(materialized="table") }} -# select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date -# """ - -# basic_python = """ -# def model(dbt, spark): -# dbt.config( -# materialized='table', -# ) -# df = dbt.ref("model") -# return df -# """ - -# basic_spark_python = """ -# def model(dbt, spark_session): -# dbt.config(materialized="table") - -# data = [(1,), (2,), (3,), (4,)] - -# df = spark_session.createDataFrame(data, ["A"]) - -# return df -# """ - -# second_sql = """ -# select * from {{ref('my_python_model')}} -# """ - -# schema_yml = """version: 2 -# models: -# - name: model -# versions: -# - v: 1 -# """ - - -# class TestBasePythonModelTests(BasePythonModelTests): -# @pytest.fixture(scope="class") -# def models(self): -# return { -# "schema.yml": schema_yml, -# "model.sql": basic_sql, -# "my_python_model.py": basic_python, -# "spark_model.py": basic_spark_python, -# "second_sql_model.sql": second_sql, -# } - - -# incremental_python = """ -# def model(dbt, spark_session): -# dbt.config(materialized="incremental") -# df = dbt.ref("model") - -# if dbt.is_incremental: -# max_from_this = ( -# f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" -# ) -# df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) - -# return df -# """ - - -# class TestBasePythonIncrementalTests(BasePythonIncrementalTests): -# @pytest.fixture(scope="class") -# def project_config_update(self): -# return {"models": {"+incremental_strategy": "append"}} - -# @pytest.fixture(scope="class") -# def models(self): -# return {"model.sql": basic_sql, "incremental.py": incremental_python} - -# def test_incremental(self, project): -# vars_dict = { -# "test_run_schema": project.test_schema, -# } - -# results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) -# assert len(results) == 2 +import pytest +import yaml + +from dbt.tests.adapter.python_model.test_python_model import ( + BasePythonIncrementalTests, + BasePythonModelTests, +) +from dbt.tests.util import run_dbt + +basic_sql = """ +{{ config(materialized="table") }} +select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date +""" + +basic_python = """ +def model(dbt, spark): + dbt.config( + materialized='table', + ) + df = dbt.ref("model") + return df +""" + +basic_spark_python = """ +def model(dbt, spark_session): + dbt.config(materialized="table") + + data = [(1,), (2,), (3,), (4,)] + + df = spark_session.createDataFrame(data, ["A"]) + + return df +""" + +second_sql = """ +select * from {{ref('my_python_model')}} +""" + +schema_yml = """version: 2 +models: + - name: model + versions: + - v: 1 +""" + + +class TestBasePythonModelTests(BasePythonModelTests): + @pytest.fixture(scope="class") + def models(self): + return { + "schema.yml": schema_yml, + "model.sql": basic_sql, + "my_python_model.py": basic_python, + "spark_model.py": basic_spark_python, + "second_sql_model.sql": second_sql, + } + + +incremental_python = """ +def model(dbt, spark_session): + dbt.config(materialized="incremental") + df = dbt.ref("model") + + if dbt.is_incremental: + max_from_this = ( + f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" + ) + df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) + + return df +""" + + +class TestBasePythonIncrementalTests(BasePythonIncrementalTests): + @pytest.fixture(scope="class") + def project_config_update(self): + return {"models": {"+incremental_strategy": "append"}} + + @pytest.fixture(scope="class") + def models(self): + return {"model.sql": basic_sql, "incremental.py": incremental_python} + + def test_incremental(self, project): + vars_dict = { + "test_run_schema": project.test_schema, + } + + results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) + assert len(results) == 2 diff --git a/tests/unit/constants.py b/tests/unit/constants.py index f86571ed..fc2a505f 100644 --- a/tests/unit/constants.py +++ b/tests/unit/constants.py @@ -8,5 +8,4 @@ S3_STAGING_DIR = "s3://my-bucket/test-dbt/" S3_TMP_TABLE_DIR = "s3://my-bucket/test-dbt-temp/" ATHENA_WORKGROUP = "dbt-athena-adapter" -SPARK_WORKGROUP = "spark" -SPARK_THREADS = 4 +SPARK_WORKGROUP = "spark" \ No newline at end of file From 217d751ee0ad2c59b33093684582a8e1a13e75cd Mon Sep 17 00:00:00 2001 From: snagapuri Date: Thu, 11 Jan 2024 14:33:24 -0500 Subject: [PATCH 69/75] fix: precommit --- README.md | 26 +++++++---- dbt/adapters/athena/config.py | 19 ++++---- dbt/adapters/athena/connections.py | 2 +- dbt/adapters/athena/python_submissions.py | 16 ++++--- dbt/adapters/athena/session.py | 46 ++++++++++++------- .../macros/adapters/python_submissions.sql | 4 +- .../models/table/create_table_as.sql | 4 +- tests/unit/constants.py | 2 +- 8 files changed, 71 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index bf384267..36aa7242 100644 --- a/README.md +++ b/README.md @@ -559,25 +559,32 @@ The adapter supports python models using [`spark`](https://docs.aws.amazon.com/a - The spark work group is added to the ~/.dbt/profiles.yml file and the profile is referenced in dbt_project.yml that will be created. It is recommended to keep this same as threads. - ### Spark specific table configuration - `timeout` (`default=43200`) - Time out in seconds for each python model execution. Defaults to 12 hours/43200 seconds. - `spark_encryption` (`default=false`) - - If this flag is set to true, encrypts data in transit between Spark nodes and also encrypts data at rest stored locally by Spark. + - If this flag is set to true, encrypts data in transit between Spark nodes and also encrypts data at rest stored + locally by Spark. - `spark_cross_account_catalog` (`default=false`) - - In spark, you can query the external account catalog and for that the consumer account has to be configured to access the producer catalog. - - If this flag is set to true, "/" can be used as the glue catalog separator. Ex: 999999999999/mydatabase.cloudfront_logs (*where *999999999999* is the external catalog id*) + - In spark, you can query the external account catalog and for that the consumer account has to be configured to + access the producer catalog. + - If this flag is set to true, "/" can be used as the glue catalog separator. + Ex: 999999999999/mydatabase.cloudfront_logs (*where *999999999999* is the external catalog id*) - `spark_requester_pays` (`default=false`) - - When an Amazon S3 bucket is configured as requester pays, the account of the user running the query is charged for data access and data transfer fees associated with the query. + - When an Amazon S3 bucket is configured as requester pays, the account of the user running the query is charged for + data access and data transfer fees associated with the query. - If this flag is set to true, requester pays S3 buckets are enabled in Athena for Spark. ### Spark notes + - A session is created for each unique engine configuration defined in the models that are part of the invocation. -- A session's idle timeout is set to 10 minutes. Within the timeout period, if there is a new calculation (spark python model) ready for execution and the engine configuration matches, the process will reuse the same session. -- Number of python models running at a time depends on the `threads`. Number of sessions created for the entire run depends on number of unique engine configurations and availability of session to maintain threads concurrency. -- For iceberg table, it is recommended to use table_properties configuration to set the format_version to 2. This is to maintain compatability between iceberg tables created by Trino with those created by Spark. +- A session's idle timeout is set to 10 minutes. Within the timeout period, if there is a new calculation + (spark python model) ready for execution and the engine configuration matches, the process will reuse the same session. +- Number of python models running at a time depends on the `threads`. Number of sessions created for the entire run + depends on number of unique engine configurations and availability of session to maintain threads concurrency. +- For iceberg table, it is recommended to use table_properties configuration to set the format_version to 2. This is to + maintain compatability between iceberg tables created by Trino with those created by Spark. ### Example models @@ -681,7 +688,8 @@ def model(dbt, spark_session): #### Known issues in python models -- Incremental models do not fully utilize spark capabilities. They depend partially on existing sql based logic which runs on trino. +- Incremental models do not fully utilize spark capabilities. They depend partially on existing sql based logic which + runs on trino. - Snapshots materializations are not supported. - Spark can only reference tables within the same catalog. diff --git a/dbt/adapters/athena/config.py b/dbt/adapters/athena/config.py index fbdddc6b..52d9ed31 100644 --- a/dbt/adapters/athena/config.py +++ b/dbt/adapters/athena/config.py @@ -5,11 +5,11 @@ from botocore import config from dbt.adapters.athena.constants import ( + DEFAULT_CALCULATION_TIMEOUT, DEFAULT_POLLING_INTERVAL, DEFAULT_SPARK_COORDINATOR_DPU_SIZE, DEFAULT_SPARK_EXECUTOR_DPU_SIZE, DEFAULT_SPARK_MAX_CONCURRENT_DPUS, - DEFAULT_CALCULATION_TIMEOUT, DEFAULT_SPARK_PROPERTIES, LOGGER, ) @@ -105,15 +105,14 @@ def set_engine_config(self) -> Dict[str, Any]: spark_cross_account_catalog = self.config.get("spark_cross_account_catalog", False) spark_requester_pays = self.config.get("spark_requester_pays", False) - default_spark_properties = {} - if table_type.lower() in ["iceberg", "hudi", "delta_lake"]: - default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get(table_type) - if spark_encryption: - default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_encryption") - if spark_cross_account_catalog: - default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_cross_account_catalog") - if spark_requester_pays: - default_spark_properties = default_spark_properties | DEFAULT_SPARK_PROPERTIES.get("spark_requester_pays") + default_spark_properties: Dict[str, str] = dict( + **DEFAULT_SPARK_PROPERTIES.get(table_type) + if table_type.lower() in ["iceberg", "hudi", "delta_lake"] + else {}, + **DEFAULT_SPARK_PROPERTIES.get("spark_encryption") if spark_encryption else {}, + **DEFAULT_SPARK_PROPERTIES.get("spark_cross_account_catalog") if spark_cross_account_catalog else {}, + **DEFAULT_SPARK_PROPERTIES.get("spark_requester_pays") if spark_requester_pays else {}, + ) default_engine_config = { "CoordinatorDpuSize": DEFAULT_SPARK_COORDINATOR_DPU_SIZE, diff --git a/dbt/adapters/athena/connections.py b/dbt/adapters/athena/connections.py index a21c5384..a39ba345 100644 --- a/dbt/adapters/athena/connections.py +++ b/dbt/adapters/athena/connections.py @@ -29,7 +29,7 @@ from tenacity.wait import wait_exponential from dbt.adapters.athena.config import get_boto3_config -from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER +from dbt.adapters.athena.constants import LOGGER from dbt.adapters.athena.session import get_boto3_session from dbt.adapters.base import Credentials from dbt.adapters.sql import SQLConnectionManager diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index ffc2845d..fa79bd21 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -119,9 +119,9 @@ def submit(self, compiled_code: str) -> Any: DbtRuntimeError: If the execution ends in a state other than "COMPLETED". """ - # I am seeing an empty calculation along with main python model code calculation is submitted for almost every model + # Seeing an empty calculation along with main python model code calculation is submitted for almost every model # Also, if not returning the result json, we are getting green ERROR messages instead of OK messages. - # And with this handling, I am not seeing the run model code in target folder every model under run folder seems to be empty. + # And with this handling, the run model code in target folder every model under run folder seems to be empty # Need to fix this work around solution if compiled_code.strip(): while True: @@ -155,7 +155,7 @@ def submit(self, compiled_code: str) -> Any: LOGGER.error(f"Unable to retrieve results: Got: {e}") result = {} return result - else: + else: return {"ResultS3Uri": "string", "ResultType": "string", "StdErrorS3Uri": "string", "StdOutS3Uri": "string"} def poll_until_session_idle(self) -> None: @@ -167,11 +167,12 @@ def poll_until_session_idle(self) -> None: """ polling_interval = self.polling_interval while True: - timer = 0 + timer: float = 0 session_status = self.get_current_session_status()["State"] if session_status in ["TERMINATING", "TERMINATED", "DEGRADED", "FAILED"]: LOGGER.debug( - f"Model {self.relation_name} - The session: {self.session_id} was not available. Got status: {session_status}. Will try with a different session." + f"Model {self.relation_name} - The session: {self.session_id} was not available. " + f"Got status: {session_status}. Will try with a different session." ) self.spark_connection.remove_terminated_session(self.session_id) if "session_id" in self.__dict__: @@ -183,7 +184,8 @@ def poll_until_session_idle(self) -> None: timer += polling_interval if timer > self.timeout: LOGGER.debug( - f"Model {self.relation_name} - Session {self.session_id} did not become free within {self.timeout} seconds. Will try with a different session." + f"Model {self.relation_name} - Session {self.session_id} did not become free within {self.timeout}" + " seconds. Will try with a different session." ) if "session_id" in self.__dict__: del self.__dict__["session_id"] @@ -213,7 +215,7 @@ def poll_until_execution_completion(self, calculation_execution_id: str) -> Any: try: polling_interval = self.polling_interval while True: - timer = 0 + timer: float = 0 execution_response = self.athena_client.get_calculation_execution( CalculationExecutionId=calculation_execution_id ) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 0cfdfd1f..fb19905d 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -1,16 +1,20 @@ +import json import threading import time -from datetime import datetime, timedelta, timezone from functools import cached_property from hashlib import md5 -from typing import Any, Dict, List +from typing import Any, Dict from uuid import UUID + import boto3 import boto3.session -import json from dbt.adapters.athena.config import get_boto3_config -from dbt.adapters.athena.constants import DEFAULT_THREAD_COUNT, LOGGER, SESSION_IDLE_TIMEOUT_MIN +from dbt.adapters.athena.constants import ( + DEFAULT_THREAD_COUNT, + LOGGER, + SESSION_IDLE_TIMEOUT_MIN, +) from dbt.contracts.connection import Connection from dbt.events.functions import get_invocation_id from dbt.exceptions import DbtRuntimeError @@ -45,7 +49,14 @@ class AthenaSparkSessionManager: A helper class to manage Athena Spark Sessions. """ - def __init__(self, credentials: Any, timeout: int, polling_interval: float, engine_config: Dict[str, int], relation_name: str = None) -> None: + def __init__( + self, + credentials: Any, + timeout: int, + polling_interval: float, + engine_config: Dict[str, int], + relation_name: str | None = None, + ) -> None: """ Initialize the AthenaSparkSessionManager instance. @@ -72,9 +83,7 @@ def spark_threads(self) -> int: int: The number of Spark threads. If not found in the profile, returns the default thread count. """ if not DEFAULT_THREAD_COUNT: - LOGGER.debug( - f"""Threads not found in profile. Got: {DEFAULT_THREAD_COUNT}""" - ) + LOGGER.debug(f"""Threads not found in profile. Got: {DEFAULT_THREAD_COUNT}""") return 1 return int(DEFAULT_THREAD_COUNT) @@ -121,9 +130,10 @@ def get_session_id(self, session_query_capacity: int = 1) -> UUID: """ Get a session ID for the Spark session. When does a new session get created: - - When thread limit not reached - - When thread limit reached but same engine configuration session is not available - - When thread limit reached and same engine configuration session exist and it is busy running a python model and has one python model in queue (determined by session_query_capacity). + - When thread limit not reached + - When thread limit reached but same engine configuration session is not available + - When thread limit reached and same engine configuration session exist and it is busy running a python model + and has one python model in queue (determined by session_query_capacity). Returns: UUID: The session ID. @@ -132,7 +142,8 @@ def get_session_id(self, session_query_capacity: int = 1) -> UUID: if len(session_list) < self.spark_threads: LOGGER.debug( - f"Within thread limit, creating new session for model: {self.relation_name} with session description: {self.session_description}." + f"Within thread limit, creating new session for model: {self.relation_name}" + f" with session description: {self.session_description}." ) return self.start_session() else: @@ -147,13 +158,15 @@ def get_session_id(self, session_query_capacity: int = 1) -> UUID: ) if matching_session_id: LOGGER.debug( - f"Over thread limit, matching session found for model: {self.relation_name} with session description: {self.session_description} and has capacity." + f"Over thread limit, matching session found for model: {self.relation_name}" + f" with session description: {self.session_description} and has capacity." ) self.set_spark_session_load(str(matching_session_id), 1) return matching_session_id else: LOGGER.debug( - f"Over thread limit, matching session not found or found with over capacity. Creating new session for model: {self.relation_name} with session description: {self.session_description}." + f"Over thread limit, matching session not found or found with over capacity. Creating new session" + f" for model: {self.relation_name} with session description: {self.session_description}." ) return self.start_session() @@ -203,13 +216,14 @@ def poll_until_session_creation(self, session_id: str) -> None: """ polling_interval = self.polling_interval while True: - timer = 0 + timer: float = 0 creation_status_response = self.get_session_status(session_id) creation_status_state = creation_status_response.get("State", "") creation_status_reason = creation_status_response.get("StateChangeReason", "") if creation_status_state in ["FAILED", "TERMINATED", "DEGRADED"]: raise DbtRuntimeError( - f"Unable to create session: {session_id}. Got status: {creation_status_state} with reason: {creation_status_reason}." + f"Unable to create session: {session_id}. Got status: {creation_status_state}" + f" with reason: {creation_status_reason}." ) elif creation_status_state == "IDLE": LOGGER.debug(f"Session: {session_id} created") diff --git a/dbt/include/athena/macros/adapters/python_submissions.sql b/dbt/include/athena/macros/adapters/python_submissions.sql index 137b117c..f668acf7 100644 --- a/dbt/include/athena/macros/adapters/python_submissions.sql +++ b/dbt/include/athena/macros/adapters/python_submissions.sql @@ -10,7 +10,7 @@ {% set bucket_count = optional_args.get("bucket_count") %} {% set field_delimiter = optional_args.get("field_delimiter") %} {% set spark_ctas = optional_args.get("spark_ctas", "") %} - + import pyspark @@ -50,7 +50,7 @@ def materialize(spark_session, df, target_relation): name="{{ target_relation.schema}}.{{ target_relation.identifier }}", ) {% endif %} - + return "Success: {{ target_relation.schema}}.{{ target_relation.identifier }}" {{ athena__py_get_spark_dbt_object() }} diff --git a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql index 5b9b9b94..64a8a31b 100644 --- a/dbt/include/athena/macros/materializations/models/table/create_table_as.sql +++ b/dbt/include/athena/macros/materializations/models/table/create_table_as.sql @@ -70,7 +70,7 @@ as {%- endset -%} - {%- endif -%} + {%- endif -%} {# {% do log('Creating table with spark and compiled code: ' ~ compiled_code) %} #} {{ athena__py_save_table_as( @@ -111,7 +111,7 @@ {%- endif -%} {%- endif %} - create table {{ relation }} + create table {{ relation }} with ( table_type='{{ table_type }}', is_external={%- if table_type == 'iceberg' -%}false{%- else -%}true{%- endif %}, diff --git a/tests/unit/constants.py b/tests/unit/constants.py index fc2a505f..f549ad64 100644 --- a/tests/unit/constants.py +++ b/tests/unit/constants.py @@ -8,4 +8,4 @@ S3_STAGING_DIR = "s3://my-bucket/test-dbt/" S3_TMP_TABLE_DIR = "s3://my-bucket/test-dbt-temp/" ATHENA_WORKGROUP = "dbt-athena-adapter" -SPARK_WORKGROUP = "spark" \ No newline at end of file +SPARK_WORKGROUP = "spark" From 53689812d4b1ce2521afdcce90c5b9fbe72edd58 Mon Sep 17 00:00:00 2001 From: snagapuri Date: Fri, 12 Jan 2024 20:07:22 -0500 Subject: [PATCH 70/75] fix: 3.9 compatability --- dbt/adapters/athena/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index fb19905d..3feaa0f5 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -55,7 +55,7 @@ def __init__( timeout: int, polling_interval: float, engine_config: Dict[str, int], - relation_name: str | None = None, + relation_name: str = "N/A", ) -> None: """ Initialize the AthenaSparkSessionManager instance. From e1e80dc80a9709f920bab4a2cc8a4390c613ffad Mon Sep 17 00:00:00 2001 From: snagapuri Date: Fri, 12 Jan 2024 21:08:03 -0500 Subject: [PATCH 71/75] chore: readme update config for spark iceberg model example --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 36aa7242..72dfcbdd 100644 --- a/README.md +++ b/README.md @@ -640,11 +640,11 @@ def model(dbt, spark_session): engine_config={ "CoordinatorDpuSize": 1, "MaxConcurrentDpus": 3, - "DefaultExecutorDpuSize": 1, - "spark_encryption": True, - "spark_cross_account_catalog": True, - "spark_requester_pays": True + "DefaultExecutorDpuSize": 1 }, + spark_encryption=True, + spark_cross_account_catalog=True, + spark_requester_pays=True polling_interval=15, timeout=120, ) From e3eb14948f8561337c2134fd26182d7c83412ecd Mon Sep 17 00:00:00 2001 From: snagapuri Date: Sun, 14 Jan 2024 19:01:09 -0500 Subject: [PATCH 72/75] fix: test config and remove obselete functions' tests --- tests/unit/test_config.py | 11 +- tests/unit/test_session.py | 204 ------------------------------------- 2 files changed, 8 insertions(+), 207 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 61005a8f..1e66b906 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -104,6 +104,11 @@ def test_set_polling_interval(self, spark_config_helper): ) def test_set_engine_config(self, spark_config_helper): engine_config = spark_config_helper.set_engine_config() - assert engine_config == spark_config_helper.config.get( - "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} - ) + diff = set(engine_config.keys()) - { + "CoordinatorDpuSize", + "MaxConcurrentDpus", + "DefaultExecutorDpuSize", + "SparkProperties", + "AdditionalConfigs", + } + assert len(diff) == 0 diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 539b6f25..c5c6fb61 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -75,59 +75,6 @@ def spark_session_manager(self, athena_credentials, athena_client, monkeypatch): monkeypatch.setattr(mock_session_manager, "athena_client", athena_client) return mock_session_manager - @pytest.mark.parametrize( - "session_status_response, expected_response", - [ - pytest.param( - { - "Sessions": [ - { - "SessionId": "635c1c6d-766c-408b-8bce-fae8ea7006f7", - "Status": { - "StartDateTime": "number", - "State": "IDLE", - }, - } - ], - }, - [UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7")], - ), - ( - { - "Sessions": [], - }, - [], - ), - ( - {}, - [], - ), - ], - ) - def test_list_sessions( - self, session_status_response, expected_response, spark_session_manager, athena_client - ) -> None: - """ - Test the _list_sessions method of the AthenaJobHelper class. - - Args: - session_status_response (dict): The response object to be returned by the mock Athena client. - expected_response (dict): The expected output of the _list_sessions method. - athena_job_helper (AthenaPythonJobHelper): An instance of the AthenaPythonJobHelper class. - athena_client (Mock): A mock instance of the Athena client. - - Returns: - None: This function only asserts the output of the _list_sessions method. - - Raises: - AssertionError: If the output of the _list_sessions method does not match the expected output. - """ - with patch.object(athena_client, "list_sessions", return_value=session_status_response), patch.object( - AthenaSparkSessionManager, "start_session", return_value=UUID("635c1c6d-766c-408b-8bce-fae8ea7006f7") - ): - response = spark_session_manager.list_sessions() - assert response == expected_response - @pytest.mark.parametrize( "session_status_response, expected_response", [ @@ -225,156 +172,5 @@ def test_get_session_status(self, session_status_response, expected_status, spar response = spark_session_manager.get_session_status("test_session_id") assert response == expected_status - @pytest.mark.parametrize( - "list_sessions_response, spark_session_locks, expected_new_sessions", - [ - ( - [ - UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"), - UUID("39cb8fc0-f855-4b67-91f1-81f068499071"), - ], - {"test_session_id": None}, - [ - UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"), - UUID("39cb8fc0-f855-4b67-91f1-81f068499071"), - ], - ), - ( - [], - {}, - [UUID("39cb8fc0-f855-4b67-91f1-81f068499071")], - ), - ( - [UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], - {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): "lock"}, - [UUID("39cb8fc0-f855-4b67-91f1-81f068499071")], - ), - ], - ) - def test_get_sessions( - self, - list_sessions_response, - spark_session_locks, - expected_new_sessions, - spark_session_manager, - athena_client, - monkeypatch, - ): - """ - Test the get_sessions function. - - Args: - self: The test class instance. - list_sessions_response (list): The response from list_sessions. - spark_session_locks (dict): The session locks. - spark_session_manager: The Spark session manager object. - athena_client: The Athena client object. - monkeypatch: The monkeypatch object for mocking. - - Returns: - None - - Raises: - AssertionError: If the retrieved sessions are not correct. - """ - monkeypatch.setattr(session, "spark_session_locks", spark_session_locks) - with patch.multiple( - spark_session_manager, - list_sessions=Mock(return_value=list_sessions_response), - poll_until_session_creation=Mock(return_value="IDLE"), - start_session=Mock(return_value=UUID("39cb8fc0-f855-4b67-91f1-81f068499071")), - ): - sessions = spark_session_manager.get_new_sessions() - assert sessions == expected_new_sessions - - @pytest.mark.parametrize( - "get_session_response, current_spark_session_locks", - [([], {}), ([UUID("106d7aca-4b3f-468d-a81d-308120e7f73c")], {})], - ) - def test_update_spark_session_locks( - self, get_session_response, current_spark_session_locks, spark_session_manager, monkeypatch - ): - """ - Test the update_spark_session_locks function. - - Args: - self: The test class instance. - get_session_response (list): The response from get_sessions. - current_spark_session_locks (dict): The current session locks. - spark_session_manager: The Spark session manager object. - monkeypatch: The monkeypatch object for mocking. - - Raises: - AssertionError: If the session locks are not updated correctly. - """ - monkeypatch.setattr(session, "spark_session_locks", current_spark_session_locks) - with patch.multiple( - spark_session_manager, - get_new_sessions=Mock(return_value=get_session_response), - ): - spark_session_manager.update_spark_session_locks() - for each_session in get_session_response: - assert each_session in session.spark_session_list.keys() - assert type(session.spark_session_list[each_session]) is not None - def test_get_session_id(self): pass - - @pytest.mark.parametrize( - "test_session_id, get_session_status_response, current_spark_session_locks, terminate_session_response", - [ - ( - "106d7aca-4b3f-468d-a81d-308120e7f73c", - { - "State": "string", - }, - {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, - {"State": "TERMINATED"}, - ), - ( - "106d7aca-4b3f-468d-a81d-308120e7f73c", - { - "State": "string", - }, - {UUID("106d7aca-4b3f-468d-a81d-308120e7f73c"): Mock()}, - {"State": "CREATED"}, - ), - ], - ) - def test_release_session_lock( - self, - test_session_id, - get_session_status_response, - current_spark_session_locks, - terminate_session_response, - spark_session_manager, - athena_client, - monkeypatch, - ): - """ - Test the release_session_lock function. - - Args: - self: The test class instance. - test_session_id (str): The ID of the test session. - get_session_status_response (dict): The response from get_session_status. - current_spark_session_locks (dict): The current session locks. - terminate_session_response (dict): The response from terminate_session. - spark_session_manager: The Spark session manager object. - athena_client: The Athena client object. - monkeypatch: The monkeypatch object for mocking. - - Raises: - AssertionError: If the session lock is not released correctly. - """ - monkeypatch.setattr(session, "spark_session_locks", current_spark_session_locks) - with patch.multiple( - spark_session_manager, - get_session_status=Mock(return_value=get_session_status_response), - ), patch.multiple( - athena_client, - terminate_session=Mock(return_value=terminate_session_response), - ): - spark_session_manager.release_session_lock(test_session_id) - assert UUID(test_session_id) in session.spark_session_list.keys() - assert type(session.spark_session_list[UUID(test_session_id)]) is not None From 7c458444928c5213e307b732827f1d36e1070a4e Mon Sep 17 00:00:00 2001 From: snagapuri Date: Mon, 15 Jan 2024 09:33:21 -0500 Subject: [PATCH 73/75] fix: precommit --- tests/unit/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index c5c6fb61..6638d605 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -4,7 +4,7 @@ import botocore.session import pytest -from dbt.adapters.athena import AthenaCredentials, session +from dbt.adapters.athena import AthenaCredentials from dbt.adapters.athena.session import AthenaSparkSessionManager, get_boto3_session from dbt.contracts.connection import Connection from dbt.exceptions import DbtRuntimeError From e571f6aa9d91f84d48c8de31e3adb2efa442e52b Mon Sep 17 00:00:00 2001 From: snagapuri Date: Tue, 16 Jan 2024 22:21:17 -0500 Subject: [PATCH 74/75] fix: unit tests --- dbt/adapters/athena/python_submissions.py | 4 +-- dbt/adapters/athena/session.py | 2 +- tests/unit/test_python_submissions.py | 31 +++++++++++------------ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/dbt/adapters/athena/python_submissions.py b/dbt/adapters/athena/python_submissions.py index fa79bd21..4fe8fa0f 100644 --- a/dbt/adapters/athena/python_submissions.py +++ b/dbt/adapters/athena/python_submissions.py @@ -166,8 +166,8 @@ def poll_until_session_idle(self) -> None: DbtRuntimeError: If the session chosen is not available or if it does not become idle within the timeout. """ polling_interval = self.polling_interval + timer: float = 0 while True: - timer: float = 0 session_status = self.get_current_session_status()["State"] if session_status in ["TERMINATING", "TERMINATED", "DEGRADED", "FAILED"]: LOGGER.debug( @@ -214,8 +214,8 @@ def poll_until_execution_completion(self, calculation_execution_id: str) -> Any: """ try: polling_interval = self.polling_interval + timer: float = 0 while True: - timer: float = 0 execution_response = self.athena_client.get_calculation_execution( CalculationExecutionId=calculation_execution_id ) diff --git a/dbt/adapters/athena/session.py b/dbt/adapters/athena/session.py index 3feaa0f5..39594938 100644 --- a/dbt/adapters/athena/session.py +++ b/dbt/adapters/athena/session.py @@ -215,8 +215,8 @@ def poll_until_session_creation(self, session_id: str) -> None: """ polling_interval = self.polling_interval + timer: float = 0 while True: - timer: float = 0 creation_status_response = self.get_session_status(session_id) creation_status_state = creation_status_response.get("State", "") creation_status_reason = creation_status_response.get("StateChangeReason", "") diff --git a/tests/unit/test_python_submissions.py b/tests/unit/test_python_submissions.py index 73189943..65961d60 100644 --- a/tests/unit/test_python_submissions.py +++ b/tests/unit/test_python_submissions.py @@ -1,4 +1,5 @@ import time +import uuid from unittest.mock import Mock, patch import pytest @@ -17,12 +18,14 @@ class TestAthenaPythonJobHelper: @pytest.fixture def parsed_model(self, request): + config: dict[str, int] = request.param.get("config", {"timeout": 1, "polling_interval": 5}) + return { "alias": "test_model", "schema": DATABASE_NAME, "config": { - "timeout": request.param.get("timeout", 7200), - "polling_interval": request.param.get("polling_interval", 5), + "timeout": config["timeout"], + "polling_interval": config["polling_interval"], "engine_config": request.param.get( "engine_config", {"CoordinatorDpuSize": 1, "MaxConcurrentDpus": 2, "DefaultExecutorDpuSize": 1} ), @@ -146,10 +149,10 @@ def test_poll_execution( ): with patch.multiple( athena_spark_session_manager, - get_session_id=Mock(return_value="test_session_id"), + get_session_id=Mock(return_value=uuid.uuid4()), ), patch.multiple( athena_client, - get_calculation_execution_status=Mock(return_value=execution_status), + get_calculation_execution=Mock(return_value=execution_status), ): def mock_sleep(_): @@ -160,26 +163,26 @@ def mock_sleep(_): assert poll_response == expected_response @pytest.mark.parametrize( - "parsed_model, test_calculation_execution_id, test_calculation_execution, test_calculation_execution_status", + "parsed_model, test_calculation_execution_id, test_calculation_execution", [ pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, {"CalculationExecutionId": "test_execution_id"}, - {"Result": {"ResultS3Uri": "test_results_s3_uri"}}, - {"Status": {"State": "COMPLETED"}}, + { + "Result": {"ResultS3Uri": "test_results_s3_uri"}, + "Status": {"State": "COMPLETED"}, + }, ), pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, {"CalculationExecutionId": "test_execution_id"}, - {"Result": {}}, - {"Status": {"State": "FAILED"}}, + {"Result": {}, "Status": {"State": "FAILED"}}, marks=pytest.mark.xfail, ), pytest.param( {"config": {"timeout": 1, "polling_interval": 5}}, {}, - {"Result": {}}, - {"Status": {"State": "FAILED"}}, + {"Result": {}, "Status": {"State": "FAILED"}}, marks=pytest.mark.xfail, ), ], @@ -189,20 +192,16 @@ def test_submission( self, test_calculation_execution_id, test_calculation_execution, - test_calculation_execution_status, athena_job_helper, athena_spark_session_manager, athena_client, ): with patch.multiple( - athena_spark_session_manager, - get_session_id=Mock(return_value="test_session_id"), - release_session_lock=Mock(), + athena_spark_session_manager, get_session_id=Mock(return_value=uuid.uuid4()) ), patch.multiple( athena_client, start_calculation_execution=Mock(return_value=test_calculation_execution_id), get_calculation_execution=Mock(return_value=test_calculation_execution), - get_calculation_execution_status=Mock(return_value=test_calculation_execution_status), ), patch.multiple( athena_job_helper, poll_until_session_idle=Mock(return_value="IDLE") ): From cb4a999d2f506cf5ef19f452af0b4ba809428d99 Mon Sep 17 00:00:00 2001 From: Avinash Santhanagopalan <43074786+Avinash-1394@users.noreply.github.com> Date: Fri, 19 Jan 2024 22:27:28 -0500 Subject: [PATCH 75/75] Comment out python submissions functional test --- .../adapter/test_python_submissions.py | 178 +++++++++--------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/tests/functional/adapter/test_python_submissions.py b/tests/functional/adapter/test_python_submissions.py index 4011bc97..671f587f 100644 --- a/tests/functional/adapter/test_python_submissions.py +++ b/tests/functional/adapter/test_python_submissions.py @@ -1,89 +1,89 @@ -import pytest -import yaml - -from dbt.tests.adapter.python_model.test_python_model import ( - BasePythonIncrementalTests, - BasePythonModelTests, -) -from dbt.tests.util import run_dbt - -basic_sql = """ -{{ config(materialized="table") }} -select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date -""" - -basic_python = """ -def model(dbt, spark): - dbt.config( - materialized='table', - ) - df = dbt.ref("model") - return df -""" - -basic_spark_python = """ -def model(dbt, spark_session): - dbt.config(materialized="table") - - data = [(1,), (2,), (3,), (4,)] - - df = spark_session.createDataFrame(data, ["A"]) - - return df -""" - -second_sql = """ -select * from {{ref('my_python_model')}} -""" - -schema_yml = """version: 2 -models: - - name: model - versions: - - v: 1 -""" - - -class TestBasePythonModelTests(BasePythonModelTests): - @pytest.fixture(scope="class") - def models(self): - return { - "schema.yml": schema_yml, - "model.sql": basic_sql, - "my_python_model.py": basic_python, - "spark_model.py": basic_spark_python, - "second_sql_model.sql": second_sql, - } - - -incremental_python = """ -def model(dbt, spark_session): - dbt.config(materialized="incremental") - df = dbt.ref("model") - - if dbt.is_incremental: - max_from_this = ( - f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" - ) - df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) - - return df -""" - - -class TestBasePythonIncrementalTests(BasePythonIncrementalTests): - @pytest.fixture(scope="class") - def project_config_update(self): - return {"models": {"+incremental_strategy": "append"}} - - @pytest.fixture(scope="class") - def models(self): - return {"model.sql": basic_sql, "incremental.py": incremental_python} - - def test_incremental(self, project): - vars_dict = { - "test_run_schema": project.test_schema, - } - - results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) - assert len(results) == 2 +# import pytest +# import yaml + +# from dbt.tests.adapter.python_model.test_python_model import ( +# BasePythonIncrementalTests, +# BasePythonModelTests, +# ) +# from dbt.tests.util import run_dbt + +# basic_sql = """ +# {{ config(materialized="table") }} +# select 1 as column_1, 2 as column_2, '{{ run_started_at.strftime("%Y-%m-%d") }}' as run_date +# """ + +# basic_python = """ +# def model(dbt, spark): +# dbt.config( +# materialized='table', +# ) +# df = dbt.ref("model") +# return df +# """ + +# basic_spark_python = """ +# def model(dbt, spark_session): +# dbt.config(materialized="table") + +# data = [(1,), (2,), (3,), (4,)] + +# df = spark_session.createDataFrame(data, ["A"]) + +# return df +# """ + +# second_sql = """ +# select * from {{ref('my_python_model')}} +# """ + +# schema_yml = """version: 2 +# models: +# - name: model +# versions: +# - v: 1 +# """ + + +# class TestBasePythonModelTests(BasePythonModelTests): +# @pytest.fixture(scope="class") +# def models(self): +# return { +# "schema.yml": schema_yml, +# "model.sql": basic_sql, +# "my_python_model.py": basic_python, +# "spark_model.py": basic_spark_python, +# "second_sql_model.sql": second_sql, +# } + + +# incremental_python = """ +# def model(dbt, spark_session): +# dbt.config(materialized="incremental") +# df = dbt.ref("model") + +# if dbt.is_incremental: +# max_from_this = ( +# f"select max(run_date) from {dbt.this.schema}.{dbt.this.identifier}" +# ) +# df = df.filter(df.run_date >= spark_session.sql(max_from_this).collect()[0][0]) + +# return df +# """ + + +# class TestBasePythonIncrementalTests(BasePythonIncrementalTests): +# @pytest.fixture(scope="class") +# def project_config_update(self): +# return {"models": {"+incremental_strategy": "append"}} + +# @pytest.fixture(scope="class") +# def models(self): +# return {"model.sql": basic_sql, "incremental.py": incremental_python} + +# def test_incremental(self, project): +# vars_dict = { +# "test_run_schema": project.test_schema, +# } + +# results = run_dbt(["run", "--vars", yaml.safe_dump(vars_dict)]) +# assert len(results) == 2

    (3 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