99
1010import time
1111from collections .abc import Callable
12- from typing import Any , TypeVar
12+ from typing import Any , Protocol , TypeVar
1313
1414import pyarrow as pa
1515from hotdata .api .query_api import QueryApi
3030T = TypeVar ("T" )
3131
3232
33+ class _StatusResponse (Protocol ):
34+ """Async resources (query runs, results) expose a status and error message."""
35+
36+ status : str
37+ error_message : str | None
38+
39+
40+ S = TypeVar ("S" , bound = _StatusResponse )
41+
42+
3343class ManagedDatabaseClient :
3444 """Managed-database client with bounded retries over hotdata-framework.
3545
@@ -39,6 +49,10 @@ class ManagedDatabaseClient:
3949 database lifecycle.
4050 """
4151
52+ _QUERY_TIMEOUT_SECONDS = 300.0
53+ _POLL_INTERVAL_SECONDS = 0.4
54+ _MAX_BACKOFF_SECONDS = 30.0
55+
4256 def __init__ (
4357 self ,
4458 * ,
@@ -100,7 +114,27 @@ def operation() -> pa.Table | None:
100114
101115 return self ._request_with_retry (operation )
102116
103- _QUERY_TIMEOUT_SECONDS = 300.0
117+ def _poll (
118+ self ,
119+ fetch : Callable [[], S ],
120+ * ,
121+ is_ready : Callable [[S ], bool ],
122+ describe : str ,
123+ ) -> S :
124+ """Poll ``fetch`` until ``is_ready`` is satisfied, or raise on failure/timeout.
125+
126+ ``failed``/``cancelled`` statuses raise ``RuntimeError``; exceeding
127+ :attr:`_QUERY_TIMEOUT_SECONDS` raises ``TimeoutError``.
128+ """
129+ deadline = time .monotonic () + self ._QUERY_TIMEOUT_SECONDS
130+ while time .monotonic () < deadline :
131+ obj = fetch ()
132+ if obj .status in ("failed" , "cancelled" ):
133+ raise RuntimeError (obj .error_message or f"{ describe } { obj .status } " )
134+ if is_ready (obj ):
135+ return obj
136+ time .sleep (self ._POLL_INTERVAL_SECONDS )
137+ raise TimeoutError (f"{ describe } timed out after { self ._QUERY_TIMEOUT_SECONDS } s" )
104138
105139 def _query_database_scoped (self , sql : str , * , database_id : str ) -> str | None :
106140 raw = QueryApi (self ._runtime .api ).query (
@@ -113,40 +147,29 @@ def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
113147 # inline preview returns, so wait for ``ready`` before the caller
114148 # fetches it as Arrow.
115149 return self ._wait_result_ready (raw .result_id )
116-
117150 if isinstance (raw , AsyncQueryResponse ):
118- runs = QueryRunsApi (self ._runtime .api )
119- deadline = time .monotonic () + self ._QUERY_TIMEOUT_SECONDS
120- result_id : str | None = None
121- while time .monotonic () < deadline :
122- run = runs .get_query_run (raw .query_run_id )
123- if run .status == "succeeded" :
124- result_id = run .result_id
125- break
126- if run .status in ("failed" , "cancelled" ):
127- raise RuntimeError (run .error_message or f"Query { run .status } " )
128- time .sleep (0.5 )
129- else :
130- raise TimeoutError (
131- f"Managed database query timed out after { self ._QUERY_TIMEOUT_SECONDS } s"
132- )
133- return self ._wait_result_ready (result_id )
134-
151+ return self ._wait_result_ready (self ._await_query_run (raw .query_run_id ))
135152 return None
136153
154+ def _await_query_run (self , query_run_id : str ) -> str | None :
155+ runs = QueryRunsApi (self ._runtime .api )
156+ run = self ._poll (
157+ lambda : runs .get_query_run (query_run_id ),
158+ is_ready = lambda r : r .status == "succeeded" ,
159+ describe = "Query" ,
160+ )
161+ return run .result_id
162+
137163 def _wait_result_ready (self , result_id : str | None ) -> str | None :
138164 if result_id is None :
139165 return None
140166 results = ResultsApi (self ._runtime .api )
141- deadline = time .monotonic () + self ._QUERY_TIMEOUT_SECONDS
142- while time .monotonic () < deadline :
143- r = results .get_result (result_id )
144- if r .status == "ready" :
145- return result_id
146- if r .status in ("failed" , "cancelled" ):
147- raise RuntimeError (r .error_message or f"Result { r .status } " )
148- time .sleep (0.3 )
149- raise TimeoutError (f"Result { result_id } not ready after { self ._QUERY_TIMEOUT_SECONDS } s" )
167+ self ._poll (
168+ lambda : results .get_result (result_id ),
169+ is_ready = lambda r : r .status == "ready" ,
170+ describe = f"Result { result_id } " ,
171+ )
172+ return result_id
150173
151174 def fetch_table_rows (self , * , database : str , schema : str , table : str ) -> list [dict [str , Any ]]:
152175 result = self .fetch_table (database = database , schema = schema , table = table )
@@ -172,8 +195,6 @@ def load_managed_table(
172195 )
173196 )
174197
175- _MAX_BACKOFF_SECONDS = 30.0
176-
177198 def _request_with_retry (self , operation : Callable [[], T ]) -> T :
178199 for attempt in range (1 , self ._max_retries + 1 ):
179200 try :
0 commit comments