Skip to content

Commit 0610701

Browse files
committed
feat(athena): track Spark Connect calc IDs and DPU stats, unify response field, tidy comments, and add functional test for the Spark 3.5 path
Signed-off-by: Daisuke Taniwaki <daisuketaniwaki@gmail.com>
1 parent de0e894 commit 0610701

14 files changed

Lines changed: 555 additions & 318 deletions

File tree

dbt-athena/hatch.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ dependencies = [
1717
"moto~=5.0.13",
1818
"pre-commit==3.7.0",
1919
"pyparsing~=3.1.4",
20+
"pyspark[connect]>=3.5.6,<3.6",
2021
"pytest>=7.0,<8.0",
2122
"pytest-dotenv~=0.5",
2223
"pytest-xdist~=3.6",
24+
"setuptools",
2325
]
2426
[envs.default.scripts]
2527
setup = [
@@ -65,8 +67,10 @@ dependencies = [
6567
"ddtrace==2.3.0",
6668
"moto~=5.0.13",
6769
"pyparsing~=3.1.4",
70+
"pyspark[connect]>=3.5.6,<3.6",
6871
"pytest>=7.0,<8.0",
6972
"pytest-xdist~=3.6",
73+
"setuptools",
7074
]
7175
[envs.ci.scripts]
7276
unit-tests = "python -m pytest tests/unit --ddtrace"
@@ -79,8 +83,10 @@ dependencies = [
7983
"ddtrace==2.3.0",
8084
"moto~=5.0.13",
8185
"pyparsing~=3.1.4",
86+
"pyspark[connect]>=3.5.6,<3.6",
8287
"pytest>=7.0,<8.0",
8388
"pytest-xdist~=3.6",
89+
"setuptools",
8490
]
8591
[envs.cd.scripts]
8692
unit-tests = "python -m pytest tests/unit --ddtrace"

dbt-athena/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ dependencies=[
3838
"pydantic>=1.10,<3.0",
3939
"tenacity>=8.2,<10.0",
4040
]
41+
[project.optional-dependencies]
42+
spark_connect = ["pyspark[connect]>=3.5.6,<3.6", "setuptools"]
43+
4144
[project.urls]
4245
Homepage = "https://github.com/dbt-labs/dbt-adapters/tree/main/dbt-athena"
4346
Documentation = "https://docs.getdbt.com"

dbt-athena/src/dbt/adapters/athena/connections.py

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
@dataclass
4949
class AthenaAdapterResponse(AdapterResponse):
5050
data_scanned_in_bytes: Optional[int] = None
51+
dpu_execution_in_millis: Optional[int] = None
5152
spark_session_id: Optional[str] = None
53+
spark_calculation_execution_ids: Optional[List[str]] = None
5254

5355

5456
@dataclass
@@ -80,26 +82,20 @@ class AthenaCredentials(Credentials):
8082
lf_tags_database: Optional[Dict[str, str]] = None
8183

8284
def __post_init__(self) -> None:
83-
# Surface mis-configured Spark Connect integer fields at profile-load
84-
# time rather than waiting until a python model runs — the misconfig
85-
# would otherwise only manifest once a Spark 3.5 model is submitted,
86-
# potentially minutes into a long dbt run.
87-
self._validate_positive_int_field("spark_connect_max_sessions")
88-
self._validate_positive_int_field("spark_connect_session_concurrency")
89-
90-
def _validate_positive_int_field(self, field_name: str) -> None:
91-
raw = getattr(self, field_name)
92-
if raw is None:
93-
return
94-
try:
95-
value = int(raw)
96-
except (TypeError, ValueError) as e:
97-
raise DbtRuntimeError(f"{field_name} must be an integer (got {raw!r}).") from e
98-
if value < 1:
99-
raise DbtRuntimeError(
100-
f"{field_name} must be >= 1 (got {value}). Omit the field to use the default."
101-
)
102-
setattr(self, field_name, value)
85+
# Fail fast at profile load so misconfig doesn't surface mid-run.
86+
for field_name in ("spark_connect_max_sessions", "spark_connect_session_concurrency"):
87+
raw = getattr(self, field_name)
88+
if raw is None:
89+
continue
90+
try:
91+
value = int(raw)
92+
except (TypeError, ValueError) as e:
93+
raise DbtRuntimeError(f"{field_name} must be an integer (got {raw!r}).") from e
94+
if value < 1:
95+
raise DbtRuntimeError(
96+
f"{field_name} must be >= 1 (got {value}). Omit the field to use the default."
97+
)
98+
setattr(self, field_name, value)
10399

104100
@property
105101
def type(self) -> str:
@@ -362,21 +358,10 @@ def process_query_stats(cursor: AthenaCursor) -> Tuple[int, int]:
362358
return cursor.rowcount, cursor.data_scanned_in_bytes
363359

364360
def cleanup_all(self) -> None:
365-
# Terminate Spark Connect sessions owned by THIS invocation so DPUs
366-
# are released immediately instead of waiting for idle timeout.
367-
# Scoping by invocation id prevents multi-invocation hosts (dbt Cloud
368-
# workers, test harnesses) from killing sessions belonging to other
369-
# live invocations that share the singleton pool.
370-
#
371-
# ``spark_connect.session`` is imported lazily because it pulls in the
372-
# pyspark runtime lookup path; importing at module load time would
373-
# force every user of the Athena connection manager (including pure
374-
# SQL workflows) to pay for Spark imports.
361+
# Lazy import: avoid pulling pyspark for SQL-only workflows.
375362
from dbt_common.invocation import get_invocation_id
376363

377-
from dbt.adapters.athena.spark_connect.session import (
378-
SparkConnectSessionPool,
379-
)
364+
from dbt.adapters.athena.spark_connect.session import SparkConnectSessionPool
380365

381366
SparkConnectSessionPool().terminate_by_invocation(get_invocation_id())
382367
super().cleanup_all()

dbt-athena/src/dbt/adapters/athena/impl.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@
99
from functools import lru_cache
1010
from textwrap import dedent
1111
from threading import Lock
12-
from typing import TYPE_CHECKING, Any, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple, Type
12+
from typing import (
13+
TYPE_CHECKING,
14+
Any,
15+
Dict,
16+
FrozenSet,
17+
Iterable,
18+
List,
19+
Optional,
20+
Set,
21+
Tuple,
22+
Type,
23+
)
1324
from urllib.parse import urlparse
1425
from uuid import uuid4
1526

@@ -265,7 +276,10 @@ def is_work_group_output_location_enforced(self) -> bool:
265276
return False
266277

267278
def _s3_table_prefix(
268-
self, s3_data_dir: Optional[str], s3_tmp_table_dir: Optional[str], is_temporary_table: bool
279+
self,
280+
s3_data_dir: Optional[str],
281+
s3_tmp_table_dir: Optional[str],
282+
is_temporary_table: bool,
269283
) -> str:
270284
"""
271285
Returns the root location for storing tables in S3.
@@ -320,15 +334,25 @@ def generate_s3_location(
320334

321335
mapping = {
322336
S3DataNaming.UNIQUE: path.join(table_prefix, str(uuid4())),
323-
S3DataNaming.TABLE: path.join(table_prefix, s3_path_table_part), # type:ignore
337+
S3DataNaming.TABLE: path.join(
338+
table_prefix,
339+
s3_path_table_part, # type:ignore[arg-type]
340+
),
324341
S3DataNaming.TABLE_UNIQUE: path.join(
325-
table_prefix, s3_path_table_part, str(uuid4()) # type:ignore
342+
table_prefix,
343+
s3_path_table_part, # type:ignore[arg-type]
344+
str(uuid4()),
326345
),
327346
S3DataNaming.SCHEMA_TABLE: path.join(
328-
table_prefix, schema_name, s3_path_table_part # type:ignore
347+
table_prefix,
348+
schema_name, # type:ignore[arg-type]
349+
s3_path_table_part, # type:ignore[arg-type]
329350
),
330351
S3DataNaming.SCHEMA_TABLE_UNIQUE: path.join(
331-
table_prefix, schema_name, s3_path_table_part, str(uuid4()) # type:ignore
352+
table_prefix,
353+
schema_name, # type:ignore[arg-type]
354+
s3_path_table_part, # type:ignore[arg-type]
355+
str(uuid4()),
332356
),
333357
}
334358

@@ -355,7 +379,9 @@ def get_glue_table(self, relation: AthenaRelation) -> Optional[GetTableResponseT
355379

356380
try:
357381
table = glue_client.get_table(
358-
CatalogId=catalog_id, DatabaseName=relation.schema, Name=relation.identifier
382+
CatalogId=catalog_id,
383+
DatabaseName=relation.schema,
384+
Name=relation.identifier,
359385
)
360386
except ClientError as e:
361387
if e.response["Error"]["Code"] == "EntityNotFoundException":
@@ -449,7 +475,10 @@ def quote(self, identifier: str) -> str: # type:ignore
449475

450476
@available
451477
def quote_seed_column(
452-
self, column: str, quote_config: Optional[bool], quote_character: Optional[str] = None
478+
self,
479+
column: str,
480+
quote_config: Optional[bool],
481+
quote_character: Optional[str] = None,
453482
) -> str:
454483
if quote_character:
455484
old_value = self.quote_character
@@ -656,7 +685,8 @@ def _get_one_catalog(
656685
for table in page["TableList"]:
657686
catalog.extend(
658687
self._get_one_table_for_catalog(
659-
table, information_schema.database # type:ignore
688+
table,
689+
information_schema.database, # type:ignore
660690
)
661691
)
662692
table = agate.Table.from_object(catalog)
@@ -679,7 +709,9 @@ def _get_one_catalog(
679709
for table in page["TableMetadataList"]:
680710
catalog.extend(
681711
self._get_one_table_for_non_glue_catalog(
682-
table, schema, information_schema.database # type:ignore
712+
table,
713+
schema,
714+
information_schema.database, # type:ignore
683715
)
684716
)
685717
table = agate.Table.from_object(catalog)
@@ -714,7 +746,11 @@ def _get_data_catalog(self, database: str) -> Optional[DataCatalogTypeDef]:
714746
config=get_boto3_config(num_retries=creds.effective_num_retries),
715747
)
716748
catalog_id = sts.get_caller_identity()["Account"]
717-
return {"Name": database, "Type": "GLUE", "Parameters": {"catalog-id": catalog_id}}
749+
return {
750+
"Name": database,
751+
"Type": "GLUE",
752+
"Parameters": {"catalog-id": catalog_id},
753+
}
718754
with boto3_client_lock:
719755
athena = client.session.client(
720756
"athena",
@@ -800,7 +836,8 @@ def _get_one_catalog_by_relations(
800836
glue_table_definition = self.get_glue_table(_rel)
801837
if glue_table_definition:
802838
_table_definition = self._get_one_table_for_catalog(
803-
glue_table_definition["Table"], _rel.database # type:ignore
839+
glue_table_definition["Table"],
840+
_rel.database, # type:ignore
804841
)
805842
_table_definitions.extend(_table_definition)
806843
table = agate.Table.from_object(_table_definitions)
@@ -1137,13 +1174,23 @@ def persist_docs_to_glue(
11371174
)
11381175

11391176
def generate_python_submission_response(self, submission_result: Any) -> AthenaAdapterResponse:
1177+
# ``code`` is intentionally left unset: AdapterResponse.code is
1178+
# reserved for driver-level status codes (SQLSTATE-style). We
1179+
# surface the success/failure signal via ``_message`` instead.
11401180
if not submission_result:
11411181
return AthenaAdapterResponse(_message="ERROR")
11421182
result = submission_result if isinstance(submission_result, dict) else {}
1183+
statistics = result.get("Statistics") or {}
1184+
dpu_execution_in_millis = (
1185+
statistics.get("DpuExecutionInMillis") if isinstance(statistics, dict) else None
1186+
)
11431187
spark_session_id = result.get("SparkSessionId")
1188+
spark_calculation_execution_ids = result.get("SparkCalculationExecutionIds")
11441189
return AthenaAdapterResponse(
11451190
_message="OK",
1191+
dpu_execution_in_millis=dpu_execution_in_millis,
11461192
spark_session_id=spark_session_id,
1193+
spark_calculation_execution_ids=spark_calculation_execution_ids,
11471194
)
11481195

11491196
@property

dbt-athena/src/dbt/adapters/athena/python_submissions.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,6 @@ def submit(self, compiled_code: str) -> Any:
140140
relation_name=self.relation_name,
141141
).submit(compiled_code)
142142

143-
# Seeing an empty calculation along with main python model code calculation is submitted for almost every model
144-
# Also, if not returning the result json, we are getting green ERROR messages instead of OK messages.
145-
# And with this handling, the run model code in target folder every model under run folder seems to be empty
146-
# Need to fix this work around solution
147143
if compiled_code.strip():
148144
while True:
149145
try:
@@ -194,22 +190,23 @@ def submit(self, compiled_code: str) -> Any:
194190
CalculationExecutionId=calculation_execution_id
195191
)
196192
result = execution_response.get("Result") or {}
193+
statistics = execution_response.get("Statistics")
194+
if statistics is not None:
195+
result["Statistics"] = statistics
197196
result["SparkSessionId"] = self.session_id
197+
result["SparkCalculationExecutionIds"] = [calculation_execution_id]
198198
except Exception as e:
199199
LOGGER.error(f"Unable to retrieve results: Got: {e}")
200-
result = {"SparkSessionId": self.session_id}
200+
# Preserve identifiers so CloudWatch / Athena console can
201+
# still be used to debug the failed fetch.
202+
result = {
203+
"SparkSessionId": self.session_id,
204+
"SparkCalculationExecutionIds": [calculation_execution_id],
205+
}
201206
return result
202207
else:
203-
# dbt submits an empty "ghost" calculation alongside every python
204-
# model to keep the adapter response shape consistent. This
205-
# branch returns placeholder data without hitting Athena.
206-
return {
207-
"ResultS3Uri": "string",
208-
"ResultType": "string",
209-
"StdErrorS3Uri": "string",
210-
"StdOutS3Uri": "string",
211-
"SparkSessionId": self.session_id,
212-
}
208+
# dbt-core wraps every python model with an empty submit; skip Athena to save DPUs.
209+
return {"SparkSessionId": self.session_id}
213210

214211
def poll_until_session_idle(self) -> None:
215212
"""

0 commit comments

Comments
 (0)