Skip to content

Commit d1c0284

Browse files
committed
fix(athena/spark): refresh AuthToken across pyspark reattach and surface session termination
pyspark's ExecutePlanResponseReattachableIterator captures gRPC metadata once at __init__ and reuses the same list for every ExecutePlan and ReattachExecute, so Athena's 30 min x-aws-proxy-auth token cannot rotate mid-stream and long-running streams die with PERMISSION_DENIED 403. Three coordinated runtime patches in pyspark_patches.py let the token rotate across pyspark's natural reattach cycle, with one PERMISSION_DENIED retry as a fallback when a 403 still slips through. job-level error handling now raises a dedicated SparkSessionTerminatedError when 403 arrives together with a dead session so the operator gets a clear signal instead of a raw gRPC trace. Verified end-to-end on dev with a 35 min UDF model: - proactive path: token rotates at the 28 min mark via the natural 2 min reattach cycle, no 403 observed - reactive path: with proactive refresh disabled, the 30 min 403 is caught and one retry recovers via the rotated token
1 parent 5fac25b commit d1c0284

11 files changed

Lines changed: 397 additions & 80 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,8 @@ def _build_spark_connect_engine_config(
204204
) -> Dict[str, Any]:
205205
"""Engine configuration for Apache Spark 3.5 (Spark Connect).
206206
207-
Spark 3.5 rejects ``CoordinatorDpuSize``, ``DefaultExecutorDpuSize``,
208-
and ``SparkProperties`` — the latter must be supplied via
209-
``Classifications`` with name ``spark-defaults``.
207+
Spark properties move from ``SparkProperties`` to a
208+
``Classifications`` entry with name ``spark-defaults``.
210209
"""
211210
engine_config: Dict[str, Any] = {
212211
"MaxConcurrentDpus": DEFAULT_SPARK_MAX_CONCURRENT_DPUS,

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,8 @@ class AthenaCredentials(Credentials):
9090
lf_tags_database: Optional[Dict[str, str]] = None
9191

9292
def __post_init__(self) -> None:
93-
# Surface mis-configured Spark Connect integer fields at profile-load
94-
# time rather than waiting until a python model runs — the misconfig
95-
# would otherwise only manifest once a Spark 3.5 model is submitted,
96-
# potentially minutes into a long dbt run.
93+
# Validate Spark Connect integer fields at profile load so a typo
94+
# cannot wait until a python model is submitted to surface.
9795
for field_name in (
9896
"spark_connect_max_sessions",
9997
"spark_connect_session_concurrency",
@@ -376,7 +374,6 @@ def process_query_stats(cursor: AthenaCursor) -> Tuple[int, int]:
376374

377375
def cleanup_all(self) -> None:
378376
# Release DPUs immediately instead of waiting for the 10-min idle timeout.
379-
# Lazy import keeps SQL-only users from paying for pyspark imports.
380377
from dbt_common.invocation import get_invocation_id
381378

382379
from dbt.adapters.athena.spark_connect.session import (
@@ -385,8 +382,10 @@ def cleanup_all(self) -> None:
385382

386383
# Scope to this invocation; the singleton is shared across invocations
387384
# in dbt Cloud workers and test harnesses.
388-
SparkConnectSessionPool().terminate_by_invocation(get_invocation_id())
389-
super().cleanup_all()
385+
try:
386+
SparkConnectSessionPool().terminate_by_invocation(get_invocation_id())
387+
finally:
388+
super().cleanup_all()
390389

391390
def cancel(self, connection: Connection) -> None:
392391
pass

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class SnapshotMigrationRequired(CompilationError):
77

88
class S3LocationException(DbtRuntimeError):
99
pass
10+
11+
12+
class SparkSessionTerminatedError(DbtRuntimeError):
13+
"""Athena ended the Spark Connect session (idle / DPU / manual stop)."""

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

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -106,24 +106,7 @@ def get_current_session_status(self) -> Any:
106106
return self.spark_connection.get_session_status(self.session_id)
107107

108108
def submit(self, compiled_code: str) -> Any:
109-
"""
110-
Submit a calculation to Athena.
111-
112-
For PySpark engine version 3, executes via the Calculations API
113-
(StartCalculationExecution). For Apache Spark 3.5+, delegates to
114-
``SparkConnectSubmitter`` which executes via Spark Connect over a
115-
gRPC channel obtained from GetSessionEndpoint.
116-
117-
Args:
118-
compiled_code (str): The compiled code to submit for execution.
119-
120-
Returns:
121-
dict: The execution result.
122-
123-
Raises:
124-
DbtRuntimeError: If the execution ends in a state other than "COMPLETED".
125-
126-
"""
109+
"""Submit ``compiled_code`` via Spark Connect (Apache Spark 3.5) or the Calculations API."""
127110
if self.config.is_spark_connect:
128111
return SparkConnectSubmitter(
129112
athena_client=self.athena_client,

dbt-athena/src/dbt/adapters/athena/spark_connect/errors.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@
1818
]
1919

2020
TRANSIENT_GRPC_STATUS_CODES = frozenset(
21-
# PERMISSION_DENIED (HTTP 403) is observed on long-running Athena Spark
22-
# sessions during regional capacity contention; the session is still
23-
# active but the gRPC frontend rejects the request. Retrying with a
24-
# fresh session recovers.
21+
# PERMISSION_DENIED reaches the job level only when pyspark_patches'
22+
# in-stream reattach has already given up, so a fresh session is the
23+
# only recovery path left.
2524
{"UNAVAILABLE", "DEADLINE_EXCEEDED", "ABORTED", "RESOURCE_EXHAUSTED", "PERMISSION_DENIED"}
2625
)
2726

@@ -51,6 +50,4 @@ def is_transient_spark_error(e: BaseException) -> bool:
5150

5251

5352
def is_grpc_permission_denied(e: BaseException) -> bool:
54-
# Athena returns 403 both for transient frontend throttling and for
55-
# capacity reclamation; callers disambiguate by querying session state.
5653
return any(name == "PERMISSION_DENIED" for name in _iter_grpc_status_codes(e))

dbt-athena/src/dbt/adapters/athena/spark_connect/job.py

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DEFAULT_SPARK_CONNECT_SESSION_CONCURRENCY,
3939
LOGGER,
4040
)
41+
from dbt.adapters.athena.exceptions import SparkSessionTerminatedError
4142
from dbt.adapters.athena.spark_connect.channel import create_athena_channel_builder
4243
from dbt.adapters.athena.spark_connect.errors import (
4344
is_grpc_permission_denied,
@@ -88,7 +89,6 @@ class _AttemptResult(NamedTuple):
8889
error: Optional[BaseException]
8990
done: bool
9091
session_id: Optional[str] = None
91-
execution_elapsed: float = 0.0
9292

9393

9494
class SparkConnectSubmitter:
@@ -204,8 +204,10 @@ def submit(self, compiled_code: str) -> SparkConnectResult:
204204

205205
for attempt in range(1, self._max_retries + 1):
206206
outcome = self._attempt(compiled_code, attempt, pool_start)
207-
if outcome.done and outcome.result is not None:
207+
if outcome.done:
208+
assert outcome.result is not None
208209
return outcome.result
210+
assert outcome.error is not None
209211
last_error = outcome.error
210212
last_session_id = outcome.session_id
211213

@@ -238,25 +240,8 @@ def submit(self, compiled_code: str) -> SparkConnectResult:
238240
f"{type(last_error).__name__}: {last_error}"
239241
) from last_error
240242

241-
def _classify_failure(
242-
self, e: BaseException, session_id: str
243-
) -> Tuple[bool, bool, Optional[str]]:
244-
if not is_transient_spark_error(e):
245-
return False, False, None
246-
247-
# 403 + dead session: Athena reclaimed capacity; a fresh session
248-
# would race the same regional shortage.
249-
if is_grpc_permission_denied(e) and not self._pool.is_session_alive(
250-
self.athena_client, session_id
251-
):
252-
reason = (
253-
"Athena terminated the session (capacity reclamation); reduce "
254-
"concurrent DPU consumption or wait for regional capacity"
255-
)
256-
LOGGER.warning(f"Model {self.relation_name} (session {session_id}) - {reason}")
257-
return False, False, reason
258-
259-
return True, True, None
243+
def _is_transient_failure(self, e: BaseException) -> bool:
244+
return is_transient_spark_error(e)
260245

261246
def _acquire_session(self, pool_timeout: float) -> str:
262247
"""Acquire a Spark Connect session from the pool."""
@@ -338,13 +323,7 @@ def _attempt(
338323
attempt: int,
339324
pool_start: float,
340325
) -> _AttemptResult:
341-
"""Run one attempt; ``done=True`` on success, ``done=False`` on transient failure.
342-
343-
Pool wait is bounded by ``_pool_acquire_timeout`` (free); the per-attempt
344-
Spark execution Timer is bounded by ``self.timeout``. Transient failures
345-
discard their session's work entirely, so each retry receives a fresh
346-
per-attempt budget rather than sharing one across attempts.
347-
"""
326+
"""Run one attempt; ``done=True`` on success, ``done=False`` on transient failure."""
348327
pool_remaining = self._pool_acquire_timeout - (time.monotonic() - pool_start)
349328
if pool_remaining <= 0:
350329
raise DbtRuntimeError(
@@ -402,7 +381,6 @@ def _on_timeout() -> None:
402381
result=SparkConnectResult(SparkConnect=True, SparkSessionId=session_id),
403382
error=None,
404383
done=True,
405-
execution_elapsed=_elapsed(),
406384
)
407385
except DbtRuntimeError:
408386
raise
@@ -412,7 +390,23 @@ def _on_timeout() -> None:
412390
f"Spark Connect execution timed out after {self.timeout} seconds."
413391
) from e
414392

415-
transient, terminate_session, fail_fast_reason = self._classify_failure(e, session_id)
393+
# 403 with a dead session means Athena ended the session itself,
394+
# so a fresh session cannot resume the work.
395+
if is_grpc_permission_denied(e) and not self._pool.is_session_alive(
396+
self.athena_client, session_id
397+
):
398+
LOGGER.error(
399+
f"Model {self.relation_name} (session {session_id}) - "
400+
f"Athena terminated the Spark session\n{traceback.format_exc()}"
401+
)
402+
raise SparkSessionTerminatedError(
403+
f"Athena terminated Spark session {session_id}; "
404+
f"check session state and workgroup DPU/quota. "
405+
f"Underlying error: {type(e).__name__}: {e}"
406+
) from e
407+
408+
transient = self._is_transient_failure(e)
409+
terminate_session = transient
416410
is_last_attempt = attempt >= self._max_retries
417411

418412
if not transient or is_last_attempt:
@@ -423,11 +417,6 @@ def _on_timeout() -> None:
423417
f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
424418
)
425419
if not transient:
426-
if fail_fast_reason:
427-
raise DbtRuntimeError(
428-
f"Spark Connect session {session_id} - {fail_fast_reason}. "
429-
f"Underlying error: {type(e).__name__}: {e}"
430-
) from e
431420
raise DbtRuntimeError(
432421
f"Spark Connect execution failed (session {session_id}): "
433422
f"{type(e).__name__}: {e}"
@@ -438,7 +427,6 @@ def _on_timeout() -> None:
438427
error=e,
439428
done=False,
440429
session_id=session_id,
441-
execution_elapsed=_elapsed(),
442430
)
443431
finally:
444432
# Cancel the watchdog timer first and wait for any already-fired

dbt-athena/src/dbt/adapters/athena/spark_connect/pyspark_patches.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
from __future__ import annotations
44

55
import threading
6+
import weakref
7+
from typing import Any
8+
9+
from dbt.adapters.events.logging import AdapterLogger
10+
11+
LOGGER = AdapterLogger(__name__)
612

713
_patches_applied = False
814
_patch_lock = threading.Lock()
@@ -17,6 +23,14 @@ def apply_pyspark_workarounds() -> None:
1723
return
1824
_neutralize_release_thread_pool_shutdown()
1925
_silence_release_all_warning()
26+
# Athena AuthToken refresh across pyspark's reattach cycle. These
27+
# three patches cooperate as one feature:
28+
# 1. Stash the ChannelBuilder on the gRPC stub.
29+
# 2. Refresh metadata via the builder before each ReattachExecute.
30+
# 3. Allow one PERMISSION_DENIED retry so a 403 still recovers.
31+
_stash_channel_builder_on_stub()
32+
_refresh_reattach_iterator_metadata()
33+
_retry_permission_denied_in_spark_client()
2034
_patches_applied = True
2135

2236

@@ -58,3 +72,103 @@ def _silence_release_all_warning() -> None:
5872
"ignore",
5973
message=r"ReleaseExecute failed with exception:.*",
6074
)
75+
76+
77+
# weakref so a reused worker thread does not pin a stale iterator.
78+
# Assumes one in-flight iterator per thread (pyspark consumes synchronously);
79+
# concurrent iterators would need a stack here.
80+
_CURRENT_ITERATOR_THREAD_LOCAL = threading.local()
81+
82+
83+
def _stash_channel_builder_on_stub() -> None:
84+
"""Cache the ChannelBuilder on the gRPC stub so the reattach iterator can find it."""
85+
from pyspark.sql.connect.client.core import SparkConnectClient
86+
87+
original_init = SparkConnectClient.__init__
88+
89+
def _patched_init(self: Any, *args: Any, **kwargs: Any) -> None:
90+
original_init(self, *args, **kwargs)
91+
builder = getattr(self, "_builder", None)
92+
stub = getattr(self, "_stub", None)
93+
if (
94+
stub is not None
95+
and builder is not None
96+
and callable(getattr(builder, "metadata", None))
97+
):
98+
stub._dbt_athena_builder = builder
99+
LOGGER.debug(
100+
"Stashed AthenaChannelBuilder on Spark Connect stub for metadata refresh."
101+
)
102+
103+
SparkConnectClient.__init__ = _patched_init
104+
105+
106+
def _refresh_reattach_iterator_metadata() -> None:
107+
"""Refresh metadata before each ReattachExecute so the AuthToken can rotate mid-stream.
108+
109+
pyspark captures ``metadata`` once at ``__init__`` and reuses the same
110+
list forever, which keeps Athena's 30-min ``x-aws-proxy-auth`` token
111+
pinned to its initial value.
112+
"""
113+
from pyspark.sql.connect.client.reattach import (
114+
ExecutePlanResponseReattachableIterator,
115+
)
116+
117+
original_init = ExecutePlanResponseReattachableIterator.__init__
118+
original_call_iter = ExecutePlanResponseReattachableIterator._call_iter
119+
120+
def _patched_init(self: Any, *args: Any, **kwargs: Any) -> None:
121+
original_init(self, *args, **kwargs)
122+
self._dbt_athena_channel_builder = getattr(self._stub, "_dbt_athena_builder", None)
123+
self._dbt_athena_pd_retried = False
124+
_CURRENT_ITERATOR_THREAD_LOCAL.iterator_ref = weakref.ref(self)
125+
126+
def _patched_call_iter(self: Any, iter_fun: Any) -> Any:
127+
if self._iterator is None:
128+
builder = getattr(self, "_dbt_athena_channel_builder", None)
129+
if builder is not None:
130+
old_token = getattr(builder, "_auth_token", None)
131+
try:
132+
self._metadata = builder.metadata()
133+
except Exception as e: # noqa: BLE001 - refresh is best-effort
134+
LOGGER.warning(f"Metadata refresh on reattach failed: {e}")
135+
else:
136+
new_token = getattr(builder, "_auth_token", None)
137+
if new_token is not None and new_token != old_token:
138+
LOGGER.debug("Reattach metadata refreshed: AuthToken rotated.")
139+
return original_call_iter(self, iter_fun)
140+
141+
ExecutePlanResponseReattachableIterator.__init__ = _patched_init
142+
ExecutePlanResponseReattachableIterator._call_iter = _patched_call_iter
143+
144+
145+
def _retry_permission_denied_in_spark_client() -> None:
146+
"""Treat PERMISSION_DENIED as retryable so a 403 from token expiry can recover.
147+
148+
pyspark's default ``retry_exception`` only retries UNAVAILABLE (and one
149+
``INTERNAL`` cursor case), so a 403 propagates out before the reattach
150+
iterator can re-issue ``ReattachExecute``. Allowing exactly one retry
151+
per iterator pairs with ``_refresh_reattach_iterator_metadata`` so the
152+
next reattach goes out with the rotated token; a second 403 means the
153+
failure is genuine and we propagate.
154+
"""
155+
import grpc
156+
from pyspark.sql.connect.client.core import SparkConnectClient
157+
158+
original = SparkConnectClient.retry_exception.__func__
159+
160+
def _patched(cls: Any, e: BaseException) -> bool:
161+
if original(cls, e):
162+
return True
163+
if not (isinstance(e, grpc.RpcError) and e.code() == grpc.StatusCode.PERMISSION_DENIED):
164+
return False
165+
iterator_ref = getattr(_CURRENT_ITERATOR_THREAD_LOCAL, "iterator_ref", None)
166+
iterator = iterator_ref() if iterator_ref is not None else None
167+
if iterator is None or getattr(iterator, "_dbt_athena_pd_retried", False):
168+
LOGGER.warning("PERMISSION_DENIED retry budget exhausted; propagating.")
169+
return False
170+
iterator._dbt_athena_pd_retried = True
171+
LOGGER.debug("PERMISSION_DENIED detected; allowing one reattach with refreshed metadata.")
172+
return True
173+
174+
SparkConnectClient.retry_exception = classmethod(_patched)

dbt-athena/src/dbt/adapters/athena/spark_connect/session.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,14 @@ def acquire(
133133
except Exception as e: # noqa: BLE001 - re-raised after cleanup
134134
start_error = e
135135

136-
# Slow Athena calls run outside the lock. Stale cleanup runs
137-
# even on start_session failure to avoid leaking prior sessions.
136+
# Stale cleanup runs even on start_session failure to avoid
137+
# leaking prior sessions.
138138
if stale_entries:
139139
self._terminate_entries(stale_entries)
140140
if start_error is not None:
141141
raise start_error
142142

143143
if global_limit_hit:
144-
# AWS rejected even though our client-side accounting said
145-
# the budget had room — usually means another dbt process
146-
# shares the account-wide DPU quota.
147144
LOGGER.warning(
148145
f"Athena rejected StartSession (account session limit) for key {key}; "
149146
f"client-side accounting saw used={budget_used} + request={dpu_request} "

0 commit comments

Comments
 (0)