@@ -19,12 +19,21 @@ class _SessionInfo(TypedDict):
1919 key : SessionKey
2020 client : AthenaClient
2121 load : int
22+ dpu : int
2223
2324
2425class _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+
2837class 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