Skip to content

Commit 90b2fe4

Browse files
committed
feat(athena): track Spark Connect DPU usage against an account-wide budget
1 parent 52f5c37 commit 90b2fe4

7 files changed

Lines changed: 425 additions & 27 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Track per-session DPU usage against an account-wide budget so the Spark Connect pool throttles before AWS rejects with `Maximum allowed sessions`, and back off on transient `required capacity not being available` errors.
3+
time: 2026-05-30T16:00:00.000000+09:00
4+
custom:
5+
Author: dtaniwaki
6+
Issue: "1854"

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class AthenaCredentials(Credentials):
8080
spark_work_group: Optional[str] = None
8181
spark_connect_max_sessions: Optional[int] = None
8282
spark_connect_session_concurrency: Optional[int] = None
83+
spark_connect_dpu_budget: Optional[int] = None
8384
s3_tmp_table_dir: Optional[str] = None
8485
# Unfortunately we can not just use dict, must be Dict because we'll get the following error:
8586
# Credentials in profile "athena", target "athena" invalid: Unable to create schema for 'dict'
@@ -91,7 +92,11 @@ def __post_init__(self) -> None:
9192
# time rather than waiting until a python model runs — the misconfig
9293
# would otherwise only manifest once a Spark 3.5 model is submitted,
9394
# potentially minutes into a long dbt run.
94-
for field_name in ("spark_connect_max_sessions", "spark_connect_session_concurrency"):
95+
for field_name in (
96+
"spark_connect_max_sessions",
97+
"spark_connect_session_concurrency",
98+
"spark_connect_dpu_budget",
99+
):
95100
raw = getattr(self, field_name)
96101
if raw is None:
97102
continue
@@ -135,6 +140,7 @@ def _connection_keys(self) -> Tuple[str, ...]:
135140
"spark_work_group",
136141
"spark_connect_max_sessions",
137142
"spark_connect_session_concurrency",
143+
"spark_connect_dpu_budget",
138144
)
139145

140146

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
DEFAULT_THREAD_COUNT = 4
44
DEFAULT_SPARK_CONNECT_MAX_SESSIONS = 4
55
DEFAULT_SPARK_CONNECT_SESSION_CONCURRENCY = 1
6+
# AWS account-level quota for concurrent Athena Spark DPUs
7+
# (service-quotas code L-5A8D5237, default 160, not adjustable without AWS Support).
8+
DEFAULT_SPARK_CONNECT_DPU_BUDGET = 160
69
DEFAULT_RETRY_ATTEMPTS = 3
710
DEFAULT_POLLING_INTERVAL = 5
811
DEFAULT_SPARK_COORDINATOR_DPU_SIZE = 1

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from dbt.adapters.athena.config import AthenaSparkSessionConfig
3232
from dbt.adapters.athena.connections import AthenaCredentials
3333
from dbt.adapters.athena.constants import (
34+
DEFAULT_SPARK_CONNECT_DPU_BUDGET,
3435
DEFAULT_SPARK_CONNECT_MAX_SESSIONS,
3536
DEFAULT_SPARK_CONNECT_SESSION_CONCURRENCY,
3637
LOGGER,
@@ -59,6 +60,26 @@ class _EndpointNotReady(Exception):
5960
"""Internal sentinel: GetSessionEndpoint should be polled again."""
6061

6162

63+
def _spark_max_executors(engine_config: EngineConfigurationTypeDef) -> Optional[int]:
64+
"""Return ``spark.dynamicAllocation.maxExecutors`` from Spark Connect engine_config.
65+
66+
Spark Connect uses Classifications (not SparkProperties) to carry
67+
spark-defaults; the value is a string and must be parsed.
68+
"""
69+
classifications = engine_config.get("Classifications") or []
70+
for entry in classifications:
71+
if entry.get("Name") != "spark-defaults":
72+
continue
73+
raw = (entry.get("Properties") or {}).get("spark.dynamicAllocation.maxExecutors")
74+
if raw is None:
75+
return None
76+
try:
77+
return int(raw)
78+
except (TypeError, ValueError):
79+
return None
80+
return None
81+
82+
6283
class _AttemptResult(NamedTuple):
6384
result: Optional[SparkConnectResult]
6485
error: Optional[BaseException]
@@ -126,6 +147,24 @@ def _session_concurrency(self) -> int:
126147
or DEFAULT_SPARK_CONNECT_SESSION_CONCURRENCY
127148
)
128149

150+
@cached_property
151+
def _dpu_budget(self) -> int:
152+
return self.credentials.spark_connect_dpu_budget or DEFAULT_SPARK_CONNECT_DPU_BUDGET
153+
154+
@cached_property
155+
def _dpu_request(self) -> int:
156+
"""DPUs reserved against the budget when starting a session.
157+
158+
``MaxConcurrentDpus`` is the AWS-side hard cap; with dynamic
159+
allocation, Spark scales up to ``maxExecutors + 1`` (executors +
160+
driver). The true peak is the smaller of the two.
161+
"""
162+
max_concurrent = int(self.engine_config["MaxConcurrentDpus"])
163+
max_executors = _spark_max_executors(self.engine_config)
164+
if max_executors is None:
165+
return max_concurrent
166+
return min(max_concurrent, max_executors + 1)
167+
129168
@cached_property
130169
def _session_description(self) -> str:
131170
return f"dbt: {get_invocation_id()} - {self._session_fingerprint}"
@@ -199,6 +238,8 @@ def _acquire_session(self) -> str:
199238
timeout=self.timeout,
200239
polling_interval=self.polling_interval,
201240
session_concurrency=self._session_concurrency,
241+
dpu_request=self._dpu_request,
242+
dpu_budget=self._dpu_budget,
202243
)
203244

204245
def _wait_for_endpoint(

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

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,21 @@ class _SessionInfo(TypedDict):
1919
key: SessionKey
2020
client: AthenaClient
2121
load: int
22+
dpu: int
2223

2324

2425
class _GlobalSessionLimitReached(Exception):
2526
"""Raised when Athena returns ``Maximum allowed sessions reached``."""
2627

2728

29+
class _AccountCapacityUnavailable(Exception):
30+
"""Raised when Athena returns ``required capacity not being available``.
31+
32+
Distinct from the account session-limit signal: this is a transient
33+
region-level capacity shortage that the DPU budget cannot predict.
34+
"""
35+
36+
2837
class SparkConnectSessionPool:
2938
"""Singleton pool of Athena Spark Connect sessions.
3039
@@ -65,36 +74,64 @@ def acquire(
6574
timeout: float,
6675
polling_interval: float,
6776
session_concurrency: int,
77+
dpu_request: int,
78+
dpu_budget: int,
6879
) -> str:
6980
"""Acquire a session for ``key``, reusing or starting one.
7081
7182
Reuses when load < ``session_concurrency``; starts new when
72-
per-key count < ``max_sessions``; waits up to ``timeout``.
83+
per-key count < ``max_sessions`` AND
84+
``used_dpu + dpu_request <= dpu_budget``; waits up to ``timeout``.
85+
86+
Raises immediately when ``dpu_request > dpu_budget`` — no future
87+
release could ever satisfy the request, so waiting would deadlock.
7388
"""
89+
if dpu_request > dpu_budget:
90+
raise DbtRuntimeError(
91+
f"Spark Connect session for key {key} requests {dpu_request} DPUs but "
92+
f"spark_connect_dpu_budget is {dpu_budget}; the session can never start. "
93+
f"Raise spark_connect_dpu_budget, or lower MaxConcurrentDpus / "
94+
f"spark.dynamicAllocation.maxExecutors for the model."
95+
)
96+
if dpu_request == dpu_budget:
97+
LOGGER.warning(
98+
f"Spark Connect session for key {key} consumes the full DPU budget "
99+
f"({dpu_request}/{dpu_budget}); other sessions will block until it releases."
100+
)
101+
74102
invocation_id = key[0]
75103
deadline = time.monotonic() + timeout
76104
time_since_eviction = self._EVICTION_INTERVAL # evict on first pass
77105

78106
while True:
79107
new_session_id: Optional[str] = None
80108
global_limit_hit = False
109+
capacity_unavailable = False
81110
start_error: Optional[BaseException] = None
111+
budget_used = 0
112+
budget_ok = False
82113
with self._lock:
83114
stale_entries = self._collect_stale_invocations(invocation_id)
84115
reuse_candidate = self._attach(key, session_concurrency)
85-
if reuse_candidate is None and self._has_room(key, max_sessions):
86-
try:
87-
new_session_id = self._start(
88-
key,
89-
athena_client,
90-
spark_work_group,
91-
engine_config,
92-
session_description,
93-
)
94-
except _GlobalSessionLimitReached:
95-
global_limit_hit = True
96-
except Exception as e: # noqa: BLE001 - re-raised after cleanup
97-
start_error = e
116+
if reuse_candidate is None:
117+
budget_used = self._used_dpu()
118+
budget_ok = budget_used + dpu_request <= dpu_budget
119+
if budget_ok and self._has_room(key, max_sessions):
120+
try:
121+
new_session_id = self._start(
122+
key,
123+
athena_client,
124+
spark_work_group,
125+
engine_config,
126+
session_description,
127+
dpu_request,
128+
)
129+
except _GlobalSessionLimitReached:
130+
global_limit_hit = True
131+
except _AccountCapacityUnavailable:
132+
capacity_unavailable = True
133+
except Exception as e: # noqa: BLE001 - re-raised after cleanup
134+
start_error = e
98135

99136
# Slow Athena calls run outside the lock. Stale cleanup runs
100137
# even on start_session failure to avoid leaking prior sessions.
@@ -103,6 +140,21 @@ def acquire(
103140
if start_error is not None:
104141
raise start_error
105142

143+
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.
147+
LOGGER.warning(
148+
f"Athena rejected StartSession (account session limit) for key {key}; "
149+
f"client-side accounting saw used={budget_used} + request={dpu_request} "
150+
f"<= budget={dpu_budget}. Another process may share the account quota."
151+
)
152+
if capacity_unavailable:
153+
LOGGER.warning(
154+
f"Athena rejected StartSession for key {key}: AWS region capacity "
155+
f"unavailable. Backing off; this is transient and budget cannot predict it."
156+
)
157+
106158
if reuse_candidate is not None:
107159
# Athena may have killed the session while it sat in the pool.
108160
if self._is_session_alive(athena_client, reuse_candidate):
@@ -127,12 +179,16 @@ def acquire(
127179
if time.monotonic() >= deadline:
128180
raise DbtRuntimeError(
129181
f"No Spark Connect session available for key {key} within {timeout}s "
130-
f"(max_sessions={max_sessions})"
182+
f"(max_sessions={max_sessions}, dpu_request={dpu_request}, "
183+
f"dpu_budget={dpu_budget}, last used_dpu={budget_used})"
131184
)
132185

133-
# Longer wait on account-level limit so we don't hammer StartSession.
186+
# Longer wait when AWS pushed back (limit or capacity) so we don't
187+
# hammer StartSession during a region-wide event.
134188
sleep_for = (
135-
self._GLOBAL_LIMIT_BACKOFF_SECONDS if global_limit_hit else polling_interval
189+
self._GLOBAL_LIMIT_BACKOFF_SECONDS
190+
if (global_limit_hit or capacity_unavailable)
191+
else polling_interval
136192
)
137193
time.sleep(sleep_for)
138194
time_since_eviction += sleep_for
@@ -169,18 +225,24 @@ def _has_room(self, key: SessionKey, max_sessions: int) -> bool:
169225
count = sum(1 for info in self._sessions.values() if info["key"] == key)
170226
return count < max_sessions
171227

228+
def _used_dpu(self) -> int:
229+
"""Sum of DPUs reserved by registered sessions. Caller must hold ``self._lock``."""
230+
return sum(info["dpu"] for info in self._sessions.values())
231+
172232
def _start(
173233
self,
174234
key: SessionKey,
175235
athena_client: AthenaClient,
176236
spark_work_group: str,
177237
engine_config: EngineConfigurationTypeDef,
178238
session_description: str,
239+
dpu: int,
179240
) -> str:
180241
"""Start a session and register it. Caller must hold ``self._lock``.
181242
182-
Translates the account-level session limit into
183-
``_GlobalSessionLimitReached``; other errors propagate.
243+
Translates two transient AWS rejections into typed exceptions for the
244+
caller's backoff loop: account session limit and region capacity
245+
unavailable. Other errors propagate.
184246
"""
185247
try:
186248
response = athena_client.start_session(
@@ -189,14 +251,21 @@ def _start(
189251
EngineConfiguration=engine_config,
190252
SessionIdleTimeoutInMinutes=SESSION_IDLE_TIMEOUT_MIN,
191253
)
192-
except Exception as e: # noqa: BLE001 - global-limit handled below
193-
if "Maximum allowed sessions" in str(e):
194-
LOGGER.warning(f"Athena session limit reached, will retry: {e}")
254+
except Exception as e: # noqa: BLE001 - transient errors handled below
255+
message = str(e)
256+
if "Maximum allowed sessions" in message:
195257
raise _GlobalSessionLimitReached() from e
258+
if "required capacity not being available" in message:
259+
raise _AccountCapacityUnavailable() from e
196260
raise
197261

198262
session_id = str(response["SessionId"])
199-
self._sessions[session_id] = {"key": key, "client": athena_client, "load": 1}
263+
self._sessions[session_id] = {
264+
"key": key,
265+
"client": athena_client,
266+
"load": 1,
267+
"dpu": dpu,
268+
}
200269
return session_id
201270

202271
def _get_session_state(self, athena_client: AthenaClient, session_id: str) -> str:

0 commit comments

Comments
 (0)