atlan-application-sdk-api 3.51.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL client implementation for database connections.
|
|
3
|
+
|
|
4
|
+
This module provides SQL client classes for both synchronous and asynchronous
|
|
5
|
+
database operations, supporting batch processing and server-side cursors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import contextlib
|
|
11
|
+
import contextvars
|
|
12
|
+
import datetime
|
|
13
|
+
import functools
|
|
14
|
+
import hashlib
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
|
|
22
|
+
from urllib.parse import quote, quote_plus
|
|
23
|
+
|
|
24
|
+
from application_sdk._logging import get_logger
|
|
25
|
+
from application_sdk._runtime.offload import (
|
|
26
|
+
CancelHandle,
|
|
27
|
+
run_in_thread,
|
|
28
|
+
submit_in_thread,
|
|
29
|
+
)
|
|
30
|
+
from application_sdk.clients._interface import ClientInterface
|
|
31
|
+
from application_sdk.clients._sql_failure_classifier import (
|
|
32
|
+
SqlFailureKind,
|
|
33
|
+
classify_sql_exception,
|
|
34
|
+
)
|
|
35
|
+
from application_sdk.clients.models import DatabaseConfig
|
|
36
|
+
from application_sdk.clients.sql_errors import (
|
|
37
|
+
EngineNotInitializedError,
|
|
38
|
+
InvalidSqlEngineTypeError,
|
|
39
|
+
MissingSqlParamError,
|
|
40
|
+
SqlAwsCredentialsError,
|
|
41
|
+
SqlBindParameterError,
|
|
42
|
+
SqlClientAuthFailedError,
|
|
43
|
+
SqlClientConfigError,
|
|
44
|
+
SqlClientConnectTimeoutError,
|
|
45
|
+
SqlClientConnectUnclassifiedError,
|
|
46
|
+
SqlClientSourceUnavailableError,
|
|
47
|
+
SqlCredentialsParseError,
|
|
48
|
+
SqlPandasResultError,
|
|
49
|
+
UnsupportedSqlCursorError,
|
|
50
|
+
)
|
|
51
|
+
from application_sdk.clients.sql_typecasters import install_tolerant_text_decoder_hook
|
|
52
|
+
from application_sdk.common.aws_utils import (
|
|
53
|
+
_sts_deadline,
|
|
54
|
+
generate_aws_rds_token_with_iam_role,
|
|
55
|
+
generate_aws_rds_token_with_iam_user,
|
|
56
|
+
)
|
|
57
|
+
from application_sdk.constants import (
|
|
58
|
+
AWS_SESSION_NAME,
|
|
59
|
+
SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
|
|
60
|
+
USE_SERVER_SIDE_CURSOR,
|
|
61
|
+
)
|
|
62
|
+
from application_sdk.credentials.utils import parse_credentials_extra
|
|
63
|
+
from application_sdk.errors import AppError, sanitize_cause_repr
|
|
64
|
+
|
|
65
|
+
logger = get_logger(__name__)
|
|
66
|
+
|
|
67
|
+
T = TypeVar("T")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SqlBindValue = (
|
|
71
|
+
str
|
|
72
|
+
| int
|
|
73
|
+
| float
|
|
74
|
+
| bool
|
|
75
|
+
| Decimal
|
|
76
|
+
| datetime.datetime
|
|
77
|
+
| datetime.date
|
|
78
|
+
| datetime.time
|
|
79
|
+
| bytes
|
|
80
|
+
| None
|
|
81
|
+
)
|
|
82
|
+
"""A value ``run_query`` can bind to a ``:name`` placeholder: a scalar every DB-API driver accepts."""
|
|
83
|
+
|
|
84
|
+
# SQL read just far enough to find placeholders: quoted text and comments are skipped whole, so a
|
|
85
|
+
# ``:name`` inside them stays literal; outside them, a ``:name`` placeholder by SQLAlchemy
|
|
86
|
+
# ``text()``'s own rule (not preceded by a colon, word character or backslash), or any other colon.
|
|
87
|
+
# Escaping and validating ``params`` both read the query through this pattern, so they cannot
|
|
88
|
+
# disagree on what is a bind. Quoting is standard SQL ('' doubles a quote; a backslash is literal)
|
|
89
|
+
# plus MySQL backtick identifiers and Postgres dollar quotes.
|
|
90
|
+
_SQL_COLON_SCAN = re.compile(
|
|
91
|
+
r"""
|
|
92
|
+
(?P<quoted>
|
|
93
|
+
'[^']*(?:''[^']*)*'
|
|
94
|
+
| "[^"]*(?:""[^"]*)*"
|
|
95
|
+
| `[^`]*`
|
|
96
|
+
| (?<!\w)\$(?P<tag>(?:[A-Za-z_]\w*)?)\$.*?\$(?P=tag)\$
|
|
97
|
+
| --[^\n]*
|
|
98
|
+
| /\*.*?\*/
|
|
99
|
+
)
|
|
100
|
+
| (?<![:\w\\]):(?P<name>\w+)
|
|
101
|
+
| :
|
|
102
|
+
""",
|
|
103
|
+
re.DOTALL | re.VERBOSE,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _escape_colons_for_text(
|
|
108
|
+
query: str, bind_names: frozenset[str] = frozenset()
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Backslash-escape literal colons so SQLAlchemy ``text()`` does not treat them as bind params.
|
|
111
|
+
|
|
112
|
+
``text()`` reads any ``:name`` as a required bind parameter and raises at compile time on
|
|
113
|
+
colon-bearing SQL -- a regex ``(?:...)``, a ``::text`` cast, a ``'12:30:00'`` literal.
|
|
114
|
+
SQLAlchemy strips the backslash before the driver, so the DB receives the literal colon.
|
|
115
|
+
|
|
116
|
+
Every colon is escaped except a ``:name`` placeholder whose name is in ``bind_names``,
|
|
117
|
+
the parameters the caller is binding. A colon inside quoted text or a comment is always
|
|
118
|
+
escaped, so ``':name'`` stays a literal. Both colons of ``::`` are escaped, so ``x::name``
|
|
119
|
+
is never taken for the bind ``:name`` while ``:name::type`` binds ``name`` and keeps the cast.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def escape(match: re.Match[str]) -> str:
|
|
123
|
+
token = match.group(0)
|
|
124
|
+
if match.group("name") in bind_names:
|
|
125
|
+
return token
|
|
126
|
+
return token.replace(":", "\\:")
|
|
127
|
+
|
|
128
|
+
return _SQL_COLON_SCAN.sub(escape, query)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _bound_params(
|
|
132
|
+
query: str, params: Mapping[str, SqlBindValue] | None
|
|
133
|
+
) -> dict[str, SqlBindValue] | None:
|
|
134
|
+
"""``params`` checked against ``query``, or None when nothing is bound.
|
|
135
|
+
|
|
136
|
+
Raises :class:`SqlBindParameterError` for a name that ``query`` never references as a
|
|
137
|
+
``:name`` placeholder outside quoted text and comments: executing anyway would run the query without that value, so a
|
|
138
|
+
filter would silently not apply.
|
|
139
|
+
"""
|
|
140
|
+
if not params:
|
|
141
|
+
return None
|
|
142
|
+
referenced = {
|
|
143
|
+
m.group("name") for m in _SQL_COLON_SCAN.finditer(query) if m.group("name")
|
|
144
|
+
}
|
|
145
|
+
for name in params:
|
|
146
|
+
if name not in referenced:
|
|
147
|
+
raise SqlBindParameterError(
|
|
148
|
+
message=f"Bind parameter {name!r} is not referenced as a :name placeholder in the query"
|
|
149
|
+
)
|
|
150
|
+
return dict(params)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if TYPE_CHECKING:
|
|
154
|
+
import pandas as pd
|
|
155
|
+
from sqlalchemy.engine import URL, Connection
|
|
156
|
+
from sqlalchemy.engine.interfaces import DBAPICursor, ExecutionContext
|
|
157
|
+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
|
|
158
|
+
from sqlalchemy.orm import Session
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class _CancellableRead:
|
|
163
|
+
"""One cancellable read, as seen from the worker thread running it."""
|
|
164
|
+
|
|
165
|
+
handle: CancelHandle
|
|
166
|
+
cancel_cursor: "Callable[[DBAPICursor], None]"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#: The read the current worker thread is running, if it is cancellable. Set
|
|
170
|
+
#: only inside the thread (run_in_thread runs each call in its own copy of the
|
|
171
|
+
#: context), so it never leaks into the task or across calls.
|
|
172
|
+
_ACTIVE_READ: contextvars.ContextVar[_CancellableRead | None] = contextvars.ContextVar(
|
|
173
|
+
"_ACTIVE_READ", default=None
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@contextlib.contextmanager
|
|
178
|
+
def _reading(read: _CancellableRead) -> Iterator[None]:
|
|
179
|
+
token = _ACTIVE_READ.set(read)
|
|
180
|
+
try:
|
|
181
|
+
yield
|
|
182
|
+
finally:
|
|
183
|
+
_ACTIVE_READ.reset(token)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _register_cursor_for_cancel(
|
|
187
|
+
conn: "Connection",
|
|
188
|
+
cursor: "DBAPICursor",
|
|
189
|
+
statement: str,
|
|
190
|
+
parameters: object,
|
|
191
|
+
context: "ExecutionContext | None",
|
|
192
|
+
executemany: bool,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""``before_cursor_execute`` listener: hand the cursor to the read's handle.
|
|
195
|
+
|
|
196
|
+
Runs on the worker thread, at the first moment the DBAPI cursor exists.
|
|
197
|
+
A statement on any other thread — or outside a cancellable read — is left
|
|
198
|
+
alone.
|
|
199
|
+
"""
|
|
200
|
+
read = _ACTIVE_READ.get()
|
|
201
|
+
if read is None:
|
|
202
|
+
return
|
|
203
|
+
read.handle.set(functools.partial(read.cancel_cursor, cursor))
|
|
204
|
+
if read.handle.requested:
|
|
205
|
+
# The caller is already gone; the action fired on set. Don't start a
|
|
206
|
+
# statement nobody is waiting for.
|
|
207
|
+
raise concurrent.futures.CancelledError
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _install_cursor_cancel_listener(engine: object) -> None:
|
|
211
|
+
"""Attach the cursor listener to ``engine`` once; skip anything not an Engine.
|
|
212
|
+
|
|
213
|
+
Done lazily on each read rather than in ``load()``, so a subclass that
|
|
214
|
+
builds its own engine is covered too. An async engine's reads don't run
|
|
215
|
+
on a worker thread, and a test double has no event surface.
|
|
216
|
+
"""
|
|
217
|
+
from sqlalchemy import event # noqa: PLC0415 — optional dep: sqlalchemy
|
|
218
|
+
from sqlalchemy.engine import Engine # noqa: PLC0415 — optional dep: sqlalchemy
|
|
219
|
+
|
|
220
|
+
if not isinstance(engine, Engine):
|
|
221
|
+
return
|
|
222
|
+
if not event.contains(engine, "before_cursor_execute", _register_cursor_for_cancel):
|
|
223
|
+
event.listen(engine, "before_cursor_execute", _register_cursor_for_cancel)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _invalidate_if_cancelled(connection: "Connection", handle: CancelHandle) -> None:
|
|
227
|
+
"""Drop a connection whose statement was cancelled instead of pooling it.
|
|
228
|
+
|
|
229
|
+
A cancelled connection may be mid-protocol, or carry server state the next
|
|
230
|
+
borrower does not expect; and a pool that pings or re-runs session setup on
|
|
231
|
+
checkout would pay a round trip to find out.
|
|
232
|
+
"""
|
|
233
|
+
if handle.requested:
|
|
234
|
+
connection.invalidate()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
#: The connect-timeout keyword of each driver that has no connect timeout by
|
|
238
|
+
#: default, so a black-holed host holds the connect (and its worker thread)
|
|
239
|
+
#: until the OS gives up. Drivers that already bound their connect (pymysql,
|
|
240
|
+
#: asyncpg, snowflake, pymssql, oracledb) are left alone so their bound is never
|
|
241
|
+
#: loosened, and so are drivers whose ``timeout`` bounds every socket read
|
|
242
|
+
#: (pg8000, redshift_connector): that would cut long queries short too.
|
|
243
|
+
_DRIVER_CONNECT_TIMEOUT_ARG: dict[str, str] = {
|
|
244
|
+
"psycopg2": "connect_timeout",
|
|
245
|
+
"psycopg": "connect_timeout",
|
|
246
|
+
"mysqldb": "connect_timeout",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#: SQLAlchemy's default driver for a URL that names only the dialect.
|
|
250
|
+
_DIALECT_DEFAULT_DRIVER: dict[str, str] = {
|
|
251
|
+
"postgresql": "psycopg2",
|
|
252
|
+
"mysql": "mysqldb",
|
|
253
|
+
"mariadb": "mysqldb",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _connect_timeout_arg(connection_string: object) -> tuple[str, "URL"] | None:
|
|
258
|
+
"""The URL's driver connect-timeout keyword and the parsed URL, if it has one.
|
|
259
|
+
|
|
260
|
+
None for a URL that does not parse or names a driver not in
|
|
261
|
+
``_DRIVER_CONNECT_TIMEOUT_ARG``.
|
|
262
|
+
"""
|
|
263
|
+
from sqlalchemy.engine import make_url # noqa: PLC0415 — optional dep: sqlalchemy
|
|
264
|
+
from sqlalchemy.exc import ArgumentError # noqa: PLC0415 — optional dep: sqlalchemy
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
url = make_url(cast(Any, connection_string))
|
|
268
|
+
except (ArgumentError, ValueError):
|
|
269
|
+
return None
|
|
270
|
+
# Read from the URL rather than ``get_driver_name()``, which imports the
|
|
271
|
+
# dialect (and fails for one that is not installed).
|
|
272
|
+
dialect, _, driver = url.drivername.lower().partition("+")
|
|
273
|
+
driver = driver or _DIALECT_DEFAULT_DRIVER.get(dialect, "")
|
|
274
|
+
arg = _DRIVER_CONNECT_TIMEOUT_ARG.get(driver)
|
|
275
|
+
return None if arg is None else (arg, url)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _with_connect_timeout(
|
|
279
|
+
connection_string: object, connect_args: dict[str, Any], seconds: float
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""``connect_args`` plus the driver's connect timeout, when it has none.
|
|
282
|
+
|
|
283
|
+
Never overrides a value already set in ``connect_args`` or in the URL's
|
|
284
|
+
query string. A URL that does not parse, or names a driver not in
|
|
285
|
+
``_DRIVER_CONNECT_TIMEOUT_ARG``, gets ``connect_args`` unchanged.
|
|
286
|
+
"""
|
|
287
|
+
found = _connect_timeout_arg(connection_string)
|
|
288
|
+
if found is None:
|
|
289
|
+
return connect_args
|
|
290
|
+
arg, url = found
|
|
291
|
+
if arg in connect_args or arg in url.query:
|
|
292
|
+
return connect_args
|
|
293
|
+
return {**connect_args, arg: max(1, math.ceil(seconds))}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _cap_next_connect_timeout(
|
|
297
|
+
engine: object, connection_string: object, seconds: float
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Cap the driver connect timeout of ``engine``'s next connect at ``seconds``.
|
|
300
|
+
|
|
301
|
+
``load()``'s ping starts after the connection string is built, which may
|
|
302
|
+
have spent much of the budget on STS. With the full timeout from
|
|
303
|
+
``connect_args`` a black-holed connect would outlive ``load()`` and hold
|
|
304
|
+
its worker thread past the deadline; capped at what is left, the thread is
|
|
305
|
+
freed when ``load()`` gives up. Only the next connect (the ping) is capped:
|
|
306
|
+
later connects keep the full value. A value of any size — ours, from
|
|
307
|
+
``connect_args`` or from the URL — is capped. Anything not an Engine (a
|
|
308
|
+
test double), or a driver not in ``_DRIVER_CONNECT_TIMEOUT_ARG``, is left
|
|
309
|
+
alone.
|
|
310
|
+
"""
|
|
311
|
+
from sqlalchemy import event # noqa: PLC0415 — optional dep: sqlalchemy
|
|
312
|
+
from sqlalchemy.engine import Engine # noqa: PLC0415 — optional dep: sqlalchemy
|
|
313
|
+
|
|
314
|
+
found = _connect_timeout_arg(connection_string)
|
|
315
|
+
if not isinstance(engine, Engine) or found is None:
|
|
316
|
+
return
|
|
317
|
+
arg, _ = found
|
|
318
|
+
cap = max(1, math.ceil(seconds))
|
|
319
|
+
|
|
320
|
+
def _cap(
|
|
321
|
+
dialect: object, conn_rec: object, cargs: object, cparams: dict[str, Any]
|
|
322
|
+
) -> None:
|
|
323
|
+
try:
|
|
324
|
+
current = int(float(cparams.get(arg, cap)))
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
return # not a number: the driver will reject it on its own
|
|
327
|
+
cparams[arg] = min(current, cap)
|
|
328
|
+
|
|
329
|
+
event.listen(engine, "do_connect", _cap, once=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _budget_left(budget: asyncio.Timeout) -> float:
|
|
333
|
+
"""Seconds until ``budget`` expires, measured on the running loop's clock."""
|
|
334
|
+
return cast(float, budget.when()) - asyncio.get_running_loop().time()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _load_budget_expired() -> SqlClientConnectTimeoutError:
|
|
338
|
+
"""The error for a ``load()`` that outran ``SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
|
|
339
|
+
|
|
340
|
+
Raised in place of the bare ``TimeoutError`` the expired budget leaves
|
|
341
|
+
behind, which says nothing about what ran out of time.
|
|
342
|
+
"""
|
|
343
|
+
return SqlClientConnectTimeoutError(
|
|
344
|
+
message=(
|
|
345
|
+
"SQL client did not connect to the source within "
|
|
346
|
+
f"{SQL_CLIENT_CONNECT_TIMEOUT_SECONDS:g}s"
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _load_failure(exc: Exception) -> AppError:
|
|
352
|
+
"""Type a failed ``load()`` by its root cause instead of calling it auth.
|
|
353
|
+
|
|
354
|
+
Classified by the same reader ``SqlApp.prime_sql_auth`` uses. An
|
|
355
|
+
unrecognised failure is reported as pending classification, not as a
|
|
356
|
+
rejected password.
|
|
357
|
+
|
|
358
|
+
No message carries driver text: SQLAlchemy embeds the connection string,
|
|
359
|
+
password included. The driver error rides ``cause``, which serialises
|
|
360
|
+
sanitised.
|
|
361
|
+
"""
|
|
362
|
+
kind = classify_sql_exception(exc)
|
|
363
|
+
if kind is SqlFailureKind.AUTH:
|
|
364
|
+
return SqlClientAuthFailedError(cause=exc)
|
|
365
|
+
if kind is SqlFailureKind.TIMEOUT:
|
|
366
|
+
return SqlClientConnectTimeoutError(cause=exc)
|
|
367
|
+
if kind is SqlFailureKind.SOURCE_UNAVAILABLE:
|
|
368
|
+
return SqlClientSourceUnavailableError(cause=exc)
|
|
369
|
+
return SqlClientConnectUnclassifiedError(cause=exc)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class BaseSQLClient(ClientInterface):
|
|
373
|
+
"""SQL client for database operations.
|
|
374
|
+
|
|
375
|
+
This class provides functionality for connecting to and querying SQL databases,
|
|
376
|
+
with support for batch processing and server-side cursors.
|
|
377
|
+
|
|
378
|
+
Attributes:
|
|
379
|
+
connection: Database connection instance.
|
|
380
|
+
engine: SQLAlchemy engine instance.
|
|
381
|
+
credentials (Dict[str, Any]): Database credentials.
|
|
382
|
+
resolved_credentials (Dict[str, Any]): Resolved credentials after reading from secret manager.
|
|
383
|
+
use_server_side_cursor (bool): Whether to use server-side cursors.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
connection = None
|
|
387
|
+
engine = None
|
|
388
|
+
credentials: dict[str, Any]
|
|
389
|
+
resolved_credentials: dict[str, Any]
|
|
390
|
+
use_server_side_cursor: bool = USE_SERVER_SIDE_CURSOR
|
|
391
|
+
DB_CONFIG: DatabaseConfig | None = None
|
|
392
|
+
|
|
393
|
+
def __init__(
|
|
394
|
+
self,
|
|
395
|
+
use_server_side_cursor: bool = USE_SERVER_SIDE_CURSOR,
|
|
396
|
+
credentials: dict[str, Any] | None = None,
|
|
397
|
+
chunk_size: int = 5000,
|
|
398
|
+
):
|
|
399
|
+
"""
|
|
400
|
+
Initialize the SQL client.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
use_server_side_cursor (bool, optional): Whether to use server-side cursors.
|
|
404
|
+
Defaults to USE_SERVER_SIDE_CURSOR.
|
|
405
|
+
credentials (Optional[Dict[str, Any]], optional): Database credentials.
|
|
406
|
+
Defaults to None, which is treated as an empty dict.
|
|
407
|
+
"""
|
|
408
|
+
self.use_server_side_cursor = use_server_side_cursor
|
|
409
|
+
self.credentials = credentials if credentials is not None else {}
|
|
410
|
+
self.resolved_credentials = {}
|
|
411
|
+
self.chunk_size = chunk_size
|
|
412
|
+
|
|
413
|
+
async def load(self, credentials: dict[str, Any]) -> None:
|
|
414
|
+
"""Load credentials and prepare engine for lazy connections.
|
|
415
|
+
|
|
416
|
+
This method now only stores credentials and creates the engine without
|
|
417
|
+
establishing a persistent connection. Connections are created on-demand.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
credentials (Dict[str, Any]): Database connection credentials.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
SqlClientAuthFailedError: If the source rejected the credentials.
|
|
424
|
+
SqlClientSourceUnavailableError: If the source could not be reached
|
|
425
|
+
(DNS, refused or filtered connection, TLS).
|
|
426
|
+
SqlClientConnectTimeoutError: If the connect or login timed out, or
|
|
427
|
+
``load()`` as a whole outran ``ATLAN_SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
|
|
428
|
+
SqlClientConnectUnclassifiedError: If the failure matched none of
|
|
429
|
+
the above.
|
|
430
|
+
AppError: Any typed error raised while connecting, unchanged — e.g.
|
|
431
|
+
``MissingSqlParamError`` or ``SqlCredentialsParseError``.
|
|
432
|
+
"""
|
|
433
|
+
if not self.DB_CONFIG:
|
|
434
|
+
raise SqlClientConfigError()
|
|
435
|
+
|
|
436
|
+
self.credentials = credentials # Update the instance credentials
|
|
437
|
+
budget = asyncio.timeout(SQL_CLIENT_CONNECT_TIMEOUT_SECONDS)
|
|
438
|
+
try:
|
|
439
|
+
from sqlalchemy import ( # noqa: PLC0415 — optional dep: sqlalchemy
|
|
440
|
+
create_engine,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
async with budget:
|
|
444
|
+
# Offloaded: for IAM-role auth this runs an STS AssumeRole,
|
|
445
|
+
# a network round trip that would otherwise block the loop.
|
|
446
|
+
# Cancelling the await does not stop the thread, so the STS
|
|
447
|
+
# call is fitted into the budget too.
|
|
448
|
+
with _sts_deadline(_budget_left(budget)):
|
|
449
|
+
connection_string = await run_in_thread(
|
|
450
|
+
self.get_sqlalchemy_connection_string
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Create engine but no persistent connection
|
|
454
|
+
self.engine = create_engine(
|
|
455
|
+
connection_string,
|
|
456
|
+
connect_args=_with_connect_timeout(
|
|
457
|
+
connection_string,
|
|
458
|
+
self.DB_CONFIG.connect_args,
|
|
459
|
+
SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
|
|
460
|
+
),
|
|
461
|
+
pool_pre_ping=self.DB_CONFIG.pool_pre_ping,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Install a tolerant UTF-8 text decoder for psycopg2/psycopg3 so
|
|
465
|
+
# query-history rows containing non-UTF-8 bytes (e.g. 0x96 from
|
|
466
|
+
# Windows-1252 paste) yield a logged U+FFFD instead of raising
|
|
467
|
+
# UnicodeDecodeError. psycopg3 is wired per connection; psycopg2 has
|
|
468
|
+
# to be remapped once per process, before any connection is opened —
|
|
469
|
+
# see sql_typecasters for why. Tracking: WARE-970, CONAT-767.
|
|
470
|
+
install_tolerant_text_decoder_hook(self.engine)
|
|
471
|
+
|
|
472
|
+
# Test connection briefly to validate credentials.
|
|
473
|
+
# Wrapped in run_in_thread because SQLAlchemy's synchronous
|
|
474
|
+
# engine.connect() blocks the event loop — critical for Temporal
|
|
475
|
+
# activities where blocking starves the auto-heartbeat. run_in_thread
|
|
476
|
+
# dispatches onto the SDK's dedicated pool rather than asyncio's
|
|
477
|
+
# shared default executor, which Temporal's own scheduling also uses.
|
|
478
|
+
# Capture engine in a local variable so the closure doesn't need to
|
|
479
|
+
# re-read self.engine (which is typed Optional) and pyright can narrow it.
|
|
480
|
+
_engine = self.engine
|
|
481
|
+
|
|
482
|
+
def _ping() -> None:
|
|
483
|
+
with _engine.connect() as _:
|
|
484
|
+
pass # Connection test successful
|
|
485
|
+
|
|
486
|
+
# Cancelling the await does not stop the thread, so the driver
|
|
487
|
+
# must give up by the deadline on its own.
|
|
488
|
+
_cap_next_connect_timeout(
|
|
489
|
+
_engine, connection_string, _budget_left(budget)
|
|
490
|
+
)
|
|
491
|
+
await run_in_thread(_ping)
|
|
492
|
+
|
|
493
|
+
# Don't store persistent connection
|
|
494
|
+
self.connection = None
|
|
495
|
+
|
|
496
|
+
# conformance: ignore[E004] exc_info omitted intentionally; SQLAlchemy embeds password in traceback; cause is sanitized and re-raised as typed error
|
|
497
|
+
except Exception as e:
|
|
498
|
+
# No exc_info here: SQLAlchemy errors embed the full connection
|
|
499
|
+
# string (including the password) in their message, and the
|
|
500
|
+
# traceback would print it verbatim into logs.
|
|
501
|
+
logger.error( # conformance: ignore[E005,L004,L009] exc_info would expose SQLAlchemy password; logs sanitized cause at failure site — raised typed error wraps only the raw unsanitized cause
|
|
502
|
+
"Error loading SQL client: %s", sanitize_cause_repr(e)
|
|
503
|
+
)
|
|
504
|
+
if self.engine:
|
|
505
|
+
self.engine.dispose()
|
|
506
|
+
self.engine = None
|
|
507
|
+
if budget.expired():
|
|
508
|
+
raise _load_budget_expired() from e
|
|
509
|
+
# Already typed (a MissingSqlParamError from building the
|
|
510
|
+
# connection string, a connector's own leaf): keep its verdict.
|
|
511
|
+
if isinstance(e, AppError):
|
|
512
|
+
raise
|
|
513
|
+
raise _load_failure(e) from e
|
|
514
|
+
|
|
515
|
+
async def close(self) -> None:
|
|
516
|
+
"""Close the database connection."""
|
|
517
|
+
if self.engine:
|
|
518
|
+
self.engine.dispose()
|
|
519
|
+
self.engine = None
|
|
520
|
+
self.connection = None # Should already be None, but ensure cleanup
|
|
521
|
+
|
|
522
|
+
def cancel_cursor(self, dbapi_cursor: "DBAPICursor") -> None:
|
|
523
|
+
"""Stop the statement running on ``dbapi_cursor``. Default: do nothing.
|
|
524
|
+
|
|
525
|
+
Called when the task awaiting a read (``get_results``,
|
|
526
|
+
``get_batched_results``, ``run_query``) is cancelled while the
|
|
527
|
+
statement is still running on its worker thread. It runs on the SDK's
|
|
528
|
+
``sdk-cancel-`` pool — a third thread, concurrently with the blocked
|
|
529
|
+
driver call — so override it only with a driver call documented as
|
|
530
|
+
safe from another thread, e.g. ``dbapi_cursor.cancel()`` or
|
|
531
|
+
``dbapi_cursor.connection.cancel()``.
|
|
532
|
+
|
|
533
|
+
DBAPI has no standard cancel, so the default is a no-op: the read
|
|
534
|
+
still hands control back at once, and the statement runs to
|
|
535
|
+
completion on its thread. Either way the connection is invalidated
|
|
536
|
+
rather than returned to the pool.
|
|
537
|
+
|
|
538
|
+
A failure is logged at WARNING and otherwise ignored. Never stop or
|
|
539
|
+
suspend a warehouse here: that is not this statement's to stop.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
dbapi_cursor: The driver's own cursor, as passed to SQLAlchemy's
|
|
543
|
+
``before_cursor_execute`` event.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
def get_iam_user_token(self):
|
|
547
|
+
"""Get an IAM user token for AWS RDS database authentication.
|
|
548
|
+
|
|
549
|
+
This method generates a temporary authentication token for IAM user-based
|
|
550
|
+
authentication with AWS RDS databases. It requires AWS access credentials
|
|
551
|
+
and database connection details.
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
str: A temporary authentication token for database access.
|
|
555
|
+
|
|
556
|
+
Raises:
|
|
557
|
+
SqlCredentialsParseError: If required credentials (username or database) are missing.
|
|
558
|
+
"""
|
|
559
|
+
extra = parse_credentials_extra(self.credentials)
|
|
560
|
+
aws_access_key_id = self.credentials.get("username")
|
|
561
|
+
aws_secret_access_key = self.credentials.get("password")
|
|
562
|
+
host = self.credentials.get("host")
|
|
563
|
+
user = extra.get("username")
|
|
564
|
+
database = extra.get("database")
|
|
565
|
+
if not user:
|
|
566
|
+
raise SqlCredentialsParseError(
|
|
567
|
+
field="username",
|
|
568
|
+
message="username is required for IAM user authentication",
|
|
569
|
+
)
|
|
570
|
+
if not database:
|
|
571
|
+
raise SqlCredentialsParseError(
|
|
572
|
+
field="database",
|
|
573
|
+
message="database is required for IAM user authentication",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
port = self.credentials.get("port")
|
|
577
|
+
region = self.credentials.get("region")
|
|
578
|
+
token = generate_aws_rds_token_with_iam_user(
|
|
579
|
+
aws_access_key_id=aws_access_key_id,
|
|
580
|
+
aws_secret_access_key=aws_secret_access_key,
|
|
581
|
+
host=host,
|
|
582
|
+
user=user,
|
|
583
|
+
port=port,
|
|
584
|
+
region=region,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
return token
|
|
588
|
+
|
|
589
|
+
def get_iam_role_token(self):
|
|
590
|
+
"""Get an IAM role token for AWS RDS database authentication.
|
|
591
|
+
|
|
592
|
+
This method generates a temporary authentication token for IAM role-based
|
|
593
|
+
authentication with AWS RDS databases. It requires an AWS role ARN and
|
|
594
|
+
database connection details.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
str: A temporary authentication token for database access.
|
|
598
|
+
|
|
599
|
+
Raises:
|
|
600
|
+
SqlAwsCredentialsError: If required credentials (aws_role_arn or database) are missing.
|
|
601
|
+
"""
|
|
602
|
+
extra = parse_credentials_extra(self.credentials)
|
|
603
|
+
aws_role_arn = extra.get("aws_role_arn")
|
|
604
|
+
database = extra.get("database")
|
|
605
|
+
external_id = extra.get("aws_external_id")
|
|
606
|
+
|
|
607
|
+
if not aws_role_arn:
|
|
608
|
+
raise SqlAwsCredentialsError(
|
|
609
|
+
message="aws_role_arn is required for IAM role authentication",
|
|
610
|
+
)
|
|
611
|
+
if not database:
|
|
612
|
+
raise SqlAwsCredentialsError(
|
|
613
|
+
message="database is required for IAM role authentication",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
session_name = AWS_SESSION_NAME
|
|
617
|
+
username = self.credentials.get("username")
|
|
618
|
+
host = self.credentials.get("host")
|
|
619
|
+
port = self.credentials.get("port")
|
|
620
|
+
region = self.credentials.get("region")
|
|
621
|
+
|
|
622
|
+
token = generate_aws_rds_token_with_iam_role(
|
|
623
|
+
role_arn=aws_role_arn,
|
|
624
|
+
host=host,
|
|
625
|
+
user=username,
|
|
626
|
+
external_id=external_id,
|
|
627
|
+
session_name=session_name,
|
|
628
|
+
port=port,
|
|
629
|
+
region=region,
|
|
630
|
+
)
|
|
631
|
+
return token
|
|
632
|
+
|
|
633
|
+
def get_auth_token(self) -> str:
|
|
634
|
+
"""Get the appropriate authentication token based on auth type.
|
|
635
|
+
|
|
636
|
+
This method determines the authentication type from credentials and returns
|
|
637
|
+
the corresponding token. Supports basic auth, IAM user, and IAM role
|
|
638
|
+
authentication methods.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
str: URL-encoded authentication token.
|
|
642
|
+
|
|
643
|
+
Raises:
|
|
644
|
+
SqlCredentialsParseError: If an invalid authentication type is specified.
|
|
645
|
+
"""
|
|
646
|
+
authType = self.credentials.get("authType", "basic") # Default to basic auth
|
|
647
|
+
token = None
|
|
648
|
+
|
|
649
|
+
match authType:
|
|
650
|
+
case "iam_user":
|
|
651
|
+
token = self.get_iam_user_token()
|
|
652
|
+
case "iam_role":
|
|
653
|
+
token = self.get_iam_role_token()
|
|
654
|
+
case "basic":
|
|
655
|
+
token = self.credentials.get("password")
|
|
656
|
+
case _:
|
|
657
|
+
raise SqlCredentialsParseError(field="authType", value_summary=authType)
|
|
658
|
+
|
|
659
|
+
# Handle None values and ensure token is a string before encoding.
|
|
660
|
+
# This token is interpolated into the userinfo of a SQLAlchemy URL,
|
|
661
|
+
# which SQLAlchemy decodes with ``urllib.parse.unquote`` (percent-only).
|
|
662
|
+
# ``quote_plus`` encodes space as ``+``, and ``unquote`` never turns
|
|
663
|
+
# ``+`` back into a space, so a password containing a space would reach
|
|
664
|
+
# the driver corrupted (CONNECT-361). ``quote`` encodes space as ``%20``,
|
|
665
|
+
# which round-trips correctly. ``safe=""`` is required so that ``/`` in
|
|
666
|
+
# the credential is also encoded (an unencoded ``/`` in userinfo would
|
|
667
|
+
# otherwise break URL parsing). Query-string params below still use
|
|
668
|
+
# ``quote_plus`` — SQLAlchemy decodes query values with ``+`` -> space.
|
|
669
|
+
encoded_token = quote(str(token or ""), safe="")
|
|
670
|
+
return encoded_token
|
|
671
|
+
|
|
672
|
+
def add_connection_params(
|
|
673
|
+
self, connection_string: str, source_connection_params: dict[str, Any]
|
|
674
|
+
) -> str:
|
|
675
|
+
"""Add additional connection parameters to a SQLAlchemy connection string.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
connection_string (str): Base SQLAlchemy connection string.
|
|
679
|
+
source_connection_params (Dict[str, Any]): Additional connection parameters
|
|
680
|
+
to append to the connection string. Keys and values must be
|
|
681
|
+
**raw, not pre-encoded** — both are URL-encoded here (via
|
|
682
|
+
``quote_plus``) to prevent ``&``/``=`` injection, so a
|
|
683
|
+
pre-encoded value would be double-encoded.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
str: Connection string with additional parameters appended.
|
|
687
|
+
"""
|
|
688
|
+
for key, value in source_connection_params.items():
|
|
689
|
+
if "?" not in connection_string:
|
|
690
|
+
connection_string += "?"
|
|
691
|
+
else:
|
|
692
|
+
connection_string += "&"
|
|
693
|
+
# URL-encode so values containing &, =, or spaces can't inject
|
|
694
|
+
# extra connection parameters (e.g. sslmode overrides).
|
|
695
|
+
connection_string += f"{quote_plus(str(key))}={quote_plus(str(value))}"
|
|
696
|
+
|
|
697
|
+
return connection_string
|
|
698
|
+
|
|
699
|
+
def get_sqlalchemy_connection_string(self) -> str:
|
|
700
|
+
"""Generate a SQLAlchemy connection string for database connection.
|
|
701
|
+
|
|
702
|
+
This method constructs a connection string using the configured database
|
|
703
|
+
parameters and credentials. It handles different authentication methods
|
|
704
|
+
and includes necessary connection parameters.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
str: Complete SQLAlchemy connection string.
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
SqlClientConfigError: If DB_CONFIG is not set.
|
|
711
|
+
MissingSqlParamError: If required connection parameters are missing.
|
|
712
|
+
"""
|
|
713
|
+
if not self.DB_CONFIG:
|
|
714
|
+
raise SqlClientConfigError()
|
|
715
|
+
|
|
716
|
+
extra = parse_credentials_extra(self.credentials)
|
|
717
|
+
|
|
718
|
+
auth_token = self.get_auth_token()
|
|
719
|
+
|
|
720
|
+
# Prepare parameters
|
|
721
|
+
param_values = {}
|
|
722
|
+
for param in self.DB_CONFIG.required:
|
|
723
|
+
if param == "password":
|
|
724
|
+
param_values[param] = auth_token
|
|
725
|
+
else:
|
|
726
|
+
value = self.credentials.get(param) or extra.get(param)
|
|
727
|
+
if value is None:
|
|
728
|
+
raise MissingSqlParamError(field=param)
|
|
729
|
+
param_values[param] = value
|
|
730
|
+
|
|
731
|
+
# Fill in base template
|
|
732
|
+
conn_str = self.DB_CONFIG.template.format(**param_values)
|
|
733
|
+
|
|
734
|
+
# Append defaults if not already in the template
|
|
735
|
+
if self.DB_CONFIG.defaults:
|
|
736
|
+
conn_str = self.add_connection_params(conn_str, self.DB_CONFIG.defaults)
|
|
737
|
+
|
|
738
|
+
if self.DB_CONFIG.parameters:
|
|
739
|
+
parameter_keys = self.DB_CONFIG.parameters
|
|
740
|
+
parameter_values = {
|
|
741
|
+
key: value
|
|
742
|
+
for key in parameter_keys
|
|
743
|
+
if (value := self.credentials.get(key) or extra.get(key)) is not None
|
|
744
|
+
}
|
|
745
|
+
if parameter_values:
|
|
746
|
+
conn_str = self.add_connection_params(conn_str, parameter_values)
|
|
747
|
+
|
|
748
|
+
return conn_str
|
|
749
|
+
|
|
750
|
+
async def run_query(
|
|
751
|
+
self,
|
|
752
|
+
query: str,
|
|
753
|
+
batch_size: int = 100000,
|
|
754
|
+
params: Mapping[str, SqlBindValue] | None = None,
|
|
755
|
+
):
|
|
756
|
+
"""Execute a SQL query and return results in batches using lazy connections.
|
|
757
|
+
|
|
758
|
+
This method creates a connection on-demand, executes the query in batches,
|
|
759
|
+
and automatically closes the connection when done. This prevents memory
|
|
760
|
+
leaks from persistent connections.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
query (str): SQL query to execute.
|
|
764
|
+
batch_size (int, optional): Number of records to fetch in each batch.
|
|
765
|
+
Defaults to 100000.
|
|
766
|
+
params (Mapping[str, SqlBindValue], optional): Values to bind to ``:name``
|
|
767
|
+
placeholders in ``query``. The driver receives them as bound
|
|
768
|
+
values, never as SQL text, so a user-supplied filter value cannot
|
|
769
|
+
change the statement. Every name must appear in ``query``.
|
|
770
|
+
|
|
771
|
+
Yields:
|
|
772
|
+
List[Dict[str, Any]]: Batches of query results, where each result is
|
|
773
|
+
a dictionary mapping column names to values.
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
EngineNotInitializedError: If engine is not initialized.
|
|
777
|
+
SqlBindParameterError: If ``params`` does not match the query.
|
|
778
|
+
"""
|
|
779
|
+
if not self.engine:
|
|
780
|
+
raise EngineNotInitializedError()
|
|
781
|
+
bound = _bound_params(query, params)
|
|
782
|
+
|
|
783
|
+
logger.debug(
|
|
784
|
+
"Running query (sha=%s, len=%d, bound=%d)",
|
|
785
|
+
hashlib.sha256(query.encode("utf-8", errors="replace")).hexdigest()[:16],
|
|
786
|
+
len(query),
|
|
787
|
+
len(bound or {}),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
_install_cursor_cancel_listener(self.engine)
|
|
791
|
+
engine = self.engine
|
|
792
|
+
# Driver calls, connect included, go through run_in_thread, so a cancelled caller stops
|
|
793
|
+
# waiting at once — but the thread keeps using the connection until the
|
|
794
|
+
# call returns. The lock keeps the close (below) from running on the
|
|
795
|
+
# connection while an abandoned call still holds it. The cancel handle
|
|
796
|
+
# is what makes that call return early: the listener registers the
|
|
797
|
+
# cursor on it, and cancel_cursor() fires off both of those threads.
|
|
798
|
+
in_use = threading.Lock()
|
|
799
|
+
handle = CancelHandle()
|
|
800
|
+
read = _CancellableRead(handle, self.cancel_cursor)
|
|
801
|
+
opened: list["Connection"] = []
|
|
802
|
+
|
|
803
|
+
def _holding_connection(func: Callable[..., T]) -> Callable[..., T]:
|
|
804
|
+
@functools.wraps(func) # keeps the callee's name for run_in_thread's label
|
|
805
|
+
def call(*args: Any) -> T:
|
|
806
|
+
with in_use, _reading(read):
|
|
807
|
+
return func(*args)
|
|
808
|
+
|
|
809
|
+
return call
|
|
810
|
+
|
|
811
|
+
def _connect() -> "Connection":
|
|
812
|
+
connection = engine.connect()
|
|
813
|
+
if handle.requested:
|
|
814
|
+
connection.close()
|
|
815
|
+
raise concurrent.futures.CancelledError
|
|
816
|
+
opened.append(connection)
|
|
817
|
+
return connection
|
|
818
|
+
|
|
819
|
+
def _close() -> None:
|
|
820
|
+
for connection in opened:
|
|
821
|
+
_invalidate_if_cancelled(connection, handle)
|
|
822
|
+
connection.close()
|
|
823
|
+
|
|
824
|
+
try:
|
|
825
|
+
connection = await run_in_thread(handle.bind(_holding_connection(_connect)))
|
|
826
|
+
if self.use_server_side_cursor:
|
|
827
|
+
connection = connection.execution_options(yield_per=batch_size)
|
|
828
|
+
|
|
829
|
+
from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
|
|
830
|
+
|
|
831
|
+
statement = text(_escape_colons_for_text(query, frozenset(bound or ())))
|
|
832
|
+
execute = handle.bind(_holding_connection(connection.execute))
|
|
833
|
+
cursor = await run_in_thread(execute, statement, bound)
|
|
834
|
+
if not cursor or not cursor.cursor:
|
|
835
|
+
raise UnsupportedSqlCursorError()
|
|
836
|
+
column_names: list[str] = [
|
|
837
|
+
description.name.lower() for description in cursor.cursor.description
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
fetchmany = handle.bind(_holding_connection(cursor.fetchmany))
|
|
841
|
+
while True:
|
|
842
|
+
rows = await run_in_thread(fetchmany, batch_size)
|
|
843
|
+
if not rows:
|
|
844
|
+
break
|
|
845
|
+
|
|
846
|
+
results = [dict(zip(column_names, row)) for row in rows]
|
|
847
|
+
yield results
|
|
848
|
+
except BaseException:
|
|
849
|
+
# Cancellation, an early aclose(), or a failure. A driver call may
|
|
850
|
+
# still be running, so closing here on the loop would block it on
|
|
851
|
+
# that call; hand the close to the pool to run once the call ends.
|
|
852
|
+
submit_in_thread(_holding_connection(_close))
|
|
853
|
+
raise
|
|
854
|
+
await run_in_thread(_holding_connection(_close))
|
|
855
|
+
|
|
856
|
+
logger.info("Query execution completed")
|
|
857
|
+
|
|
858
|
+
def _execute_pandas_query(
|
|
859
|
+
self, conn, query, chunksize: int | None
|
|
860
|
+
) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
|
|
861
|
+
"""Helper function to execute SQL query using pandas.
|
|
862
|
+
The function is responsible for using import_optional_dependency method of the pandas library to import sqlalchemy
|
|
863
|
+
This function helps pandas in determining weather to use the sqlalchemy connection object and constructs like text()
|
|
864
|
+
or use the underlying database connection object. This has been done to make sure connectors like the Redshift connector,
|
|
865
|
+
which do not support the sqlalchemy connection object, can be made compatible with the application-sdk.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
conn: Database connection object.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
|
|
872
|
+
or iterator of DataFrames if chunked.
|
|
873
|
+
"""
|
|
874
|
+
import pandas as pd # noqa: PLC0415 — optional dep: pandas
|
|
875
|
+
from pandas.compat._optional import ( # noqa: PLC0415 — optional dep: pandas
|
|
876
|
+
import_optional_dependency,
|
|
877
|
+
)
|
|
878
|
+
from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
|
|
879
|
+
|
|
880
|
+
if import_optional_dependency("sqlalchemy", errors="ignore"):
|
|
881
|
+
return pd.read_sql_query(
|
|
882
|
+
text(_escape_colons_for_text(query)), conn, chunksize=chunksize
|
|
883
|
+
)
|
|
884
|
+
else:
|
|
885
|
+
dbapi_conn = getattr(conn, "connection", None)
|
|
886
|
+
return pd.read_sql_query(query, dbapi_conn, chunksize=chunksize)
|
|
887
|
+
|
|
888
|
+
def _read_sql_query(
|
|
889
|
+
self, session: "Session", query: str, chunksize: int | None
|
|
890
|
+
) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
|
|
891
|
+
"""Execute SQL query using the provided session.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
session: SQLAlchemy session for database operations.
|
|
895
|
+
|
|
896
|
+
Returns:
|
|
897
|
+
Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
|
|
898
|
+
or iterator of DataFrames if chunked.
|
|
899
|
+
"""
|
|
900
|
+
conn = session.connection()
|
|
901
|
+
return self._execute_pandas_query(conn, query, chunksize=chunksize)
|
|
902
|
+
|
|
903
|
+
def _execute_query(
|
|
904
|
+
self, query: str, chunksize: int | None
|
|
905
|
+
) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
|
|
906
|
+
"""Execute SQL query using the provided engine and pandas.
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
|
|
910
|
+
or iterator of DataFrames if chunked.
|
|
911
|
+
"""
|
|
912
|
+
if not self.engine:
|
|
913
|
+
raise EngineNotInitializedError()
|
|
914
|
+
|
|
915
|
+
with self.engine.connect() as conn:
|
|
916
|
+
try:
|
|
917
|
+
return self._execute_pandas_query(conn, query, chunksize)
|
|
918
|
+
finally:
|
|
919
|
+
read = _ACTIVE_READ.get()
|
|
920
|
+
if read is not None:
|
|
921
|
+
_invalidate_if_cancelled(conn, read.handle)
|
|
922
|
+
|
|
923
|
+
async def _execute_async_read_operation(
|
|
924
|
+
self, query: str, chunksize: int | None
|
|
925
|
+
) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
|
|
926
|
+
"""Helper to execute async read operation with either async session or thread executor."""
|
|
927
|
+
if isinstance(self.engine, str):
|
|
928
|
+
raise InvalidSqlEngineTypeError()
|
|
929
|
+
|
|
930
|
+
from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 — optional dep: sqlalchemy
|
|
931
|
+
AsyncEngine,
|
|
932
|
+
AsyncSession,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
async_session = None
|
|
936
|
+
if self.engine and isinstance(self.engine, AsyncEngine):
|
|
937
|
+
from sqlalchemy.orm import ( # noqa: PLC0415 — optional dep: sqlalchemy
|
|
938
|
+
sessionmaker,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
async_session = sessionmaker(
|
|
942
|
+
self.engine, expire_on_commit=False, class_=AsyncSession
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
if async_session:
|
|
946
|
+
async with async_session() as session:
|
|
947
|
+
return await session.run_sync(
|
|
948
|
+
self._read_sql_query, query, chunksize=chunksize
|
|
949
|
+
)
|
|
950
|
+
else:
|
|
951
|
+
# run_in_thread, not a per-call ThreadPoolExecutor: leaving that
|
|
952
|
+
# executor's `with` block on cancellation calls shutdown(wait=True)
|
|
953
|
+
# on the loop thread, which freezes the loop until the query ends.
|
|
954
|
+
# The shared pool lets a cancelled read return at once while its
|
|
955
|
+
# thread drains on its own — and the handle lets cancel_cursor()
|
|
956
|
+
# cut that drain short at the driver.
|
|
957
|
+
_install_cursor_cancel_listener(self.engine)
|
|
958
|
+
handle = CancelHandle()
|
|
959
|
+
read = _CancellableRead(handle, self.cancel_cursor)
|
|
960
|
+
execute_query = self._execute_query
|
|
961
|
+
|
|
962
|
+
@functools.wraps(execute_query) # keeps the name for the hold label
|
|
963
|
+
def _cancellable_query(
|
|
964
|
+
query: str, chunksize: int | None
|
|
965
|
+
) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
|
|
966
|
+
with _reading(read):
|
|
967
|
+
return execute_query(query, chunksize)
|
|
968
|
+
|
|
969
|
+
return await run_in_thread(
|
|
970
|
+
handle.bind(_cancellable_query), query, chunksize
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
async def get_batched_results(
|
|
974
|
+
self,
|
|
975
|
+
query: str,
|
|
976
|
+
) -> AsyncIterator["pd.DataFrame"] | Iterator["pd.DataFrame"]: # type: ignore
|
|
977
|
+
"""Get query results as batched pandas DataFrames asynchronously.
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
AsyncIterator["pd.DataFrame"]: Async iterator yielding batches of query results.
|
|
981
|
+
|
|
982
|
+
Raises:
|
|
983
|
+
InvalidSqlEngineTypeError: If engine is a string instead of a SQLAlchemy engine.
|
|
984
|
+
SqlPandasResultError: If there's an error executing the query.
|
|
985
|
+
"""
|
|
986
|
+
try:
|
|
987
|
+
# We cast to Iterator because passing chunk_size guarantees an Iterator return
|
|
988
|
+
result = await self._execute_async_read_operation(query, self.chunk_size)
|
|
989
|
+
return cast(Iterator["pd.DataFrame"], result)
|
|
990
|
+
except AppError:
|
|
991
|
+
raise
|
|
992
|
+
# conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
|
|
993
|
+
except Exception as e:
|
|
994
|
+
# retryable=True: unclassified driver exception — do not override the
|
|
995
|
+
# activity's declared retry policy. See SqlPandasResultError docstring.
|
|
996
|
+
raise SqlPandasResultError(
|
|
997
|
+
message="Error reading batched data from SQL", cause=e, retryable=True
|
|
998
|
+
) from e
|
|
999
|
+
|
|
1000
|
+
async def get_results(self, query: str) -> "pd.DataFrame":
|
|
1001
|
+
"""Get all query results as a single pandas DataFrame asynchronously.
|
|
1002
|
+
|
|
1003
|
+
Returns:
|
|
1004
|
+
pd.DataFrame: Query results as a DataFrame.
|
|
1005
|
+
|
|
1006
|
+
Raises:
|
|
1007
|
+
InvalidSqlEngineTypeError: If engine is a string instead of a SQLAlchemy engine.
|
|
1008
|
+
SqlPandasResultError: If there's an error executing the query.
|
|
1009
|
+
"""
|
|
1010
|
+
try:
|
|
1011
|
+
result = await self._execute_async_read_operation(query, None)
|
|
1012
|
+
import pandas as pd # noqa: PLC0415 — optional dep: pandas
|
|
1013
|
+
|
|
1014
|
+
if isinstance(result, pd.DataFrame):
|
|
1015
|
+
return result
|
|
1016
|
+
raise SqlPandasResultError(
|
|
1017
|
+
invariant="_execute_async_read_operation must return a pd.DataFrame"
|
|
1018
|
+
)
|
|
1019
|
+
except AppError:
|
|
1020
|
+
raise
|
|
1021
|
+
# conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
|
|
1022
|
+
except Exception as e:
|
|
1023
|
+
# retryable=True: unclassified driver exception — do not override the
|
|
1024
|
+
# activity's declared retry policy. See SqlPandasResultError docstring.
|
|
1025
|
+
raise SqlPandasResultError(cause=e, retryable=True) from e
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
class AsyncBaseSQLClient(BaseSQLClient):
|
|
1029
|
+
"""Asynchronous SQL client for database operations.
|
|
1030
|
+
|
|
1031
|
+
This class extends BaseSQLClient to provide asynchronous database operations,
|
|
1032
|
+
with support for batch processing and server-side cursors. It uses SQLAlchemy's
|
|
1033
|
+
async engine and connection interfaces for non-blocking database operations.
|
|
1034
|
+
|
|
1035
|
+
Attributes:
|
|
1036
|
+
connection (AsyncConnection): Async database connection instance.
|
|
1037
|
+
engine (AsyncEngine): Async SQLAlchemy engine instance.
|
|
1038
|
+
credentials (Dict[str, Any]): Database credentials.
|
|
1039
|
+
use_server_side_cursor (bool): Whether to use server-side cursors.
|
|
1040
|
+
"""
|
|
1041
|
+
|
|
1042
|
+
connection: "AsyncConnection"
|
|
1043
|
+
engine: "AsyncEngine"
|
|
1044
|
+
|
|
1045
|
+
async def load(self, credentials: dict[str, Any]) -> None:
|
|
1046
|
+
"""Load credentials and prepare async engine for lazy connections.
|
|
1047
|
+
|
|
1048
|
+
This method stores credentials and creates an async engine without establishing
|
|
1049
|
+
a persistent connection. Connections are created on-demand for better memory efficiency.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
credentials (Dict[str, Any]): Database connection credentials including
|
|
1053
|
+
host, port, username, password, and other connection parameters.
|
|
1054
|
+
|
|
1055
|
+
Raises:
|
|
1056
|
+
SqlClientConfigError: If DB_CONFIG is not set.
|
|
1057
|
+
SqlClientAuthFailedError: If the source rejected the credentials.
|
|
1058
|
+
SqlClientSourceUnavailableError: If the source could not be reached
|
|
1059
|
+
(DNS, refused or filtered connection, TLS).
|
|
1060
|
+
SqlClientConnectTimeoutError: If the connect or login timed out, or
|
|
1061
|
+
``load()`` as a whole outran ``ATLAN_SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
|
|
1062
|
+
SqlClientConnectUnclassifiedError: If the failure matched none of
|
|
1063
|
+
the above.
|
|
1064
|
+
AppError: Any typed error raised while connecting, unchanged — e.g.
|
|
1065
|
+
``MissingSqlParamError`` or ``SqlCredentialsParseError``.
|
|
1066
|
+
"""
|
|
1067
|
+
self.credentials = credentials
|
|
1068
|
+
if not self.DB_CONFIG:
|
|
1069
|
+
raise SqlClientConfigError()
|
|
1070
|
+
|
|
1071
|
+
budget = asyncio.timeout(SQL_CLIENT_CONNECT_TIMEOUT_SECONDS)
|
|
1072
|
+
try:
|
|
1073
|
+
from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 — optional dep: sqlalchemy
|
|
1074
|
+
create_async_engine,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
async with budget:
|
|
1078
|
+
# Offloaded: for IAM-role auth this runs an STS AssumeRole,
|
|
1079
|
+
# a network round trip that would otherwise block the loop.
|
|
1080
|
+
# Cancelling the await does not stop the thread, so the STS
|
|
1081
|
+
# call is fitted into the budget too.
|
|
1082
|
+
with _sts_deadline(_budget_left(budget)):
|
|
1083
|
+
connection_string = await run_in_thread(
|
|
1084
|
+
self.get_sqlalchemy_connection_string
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# Create async engine but no persistent connection
|
|
1088
|
+
self.engine = create_async_engine(
|
|
1089
|
+
connection_string,
|
|
1090
|
+
connect_args=_with_connect_timeout(
|
|
1091
|
+
connection_string,
|
|
1092
|
+
self.DB_CONFIG.connect_args,
|
|
1093
|
+
SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
|
|
1094
|
+
),
|
|
1095
|
+
pool_pre_ping=self.DB_CONFIG.pool_pre_ping,
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# Same WARE-970 tolerant-decoder hook as the sync client. Async
|
|
1099
|
+
# engines surface DBAPI events on the underlying sync_engine.
|
|
1100
|
+
install_tolerant_text_decoder_hook(self.engine.sync_engine)
|
|
1101
|
+
|
|
1102
|
+
# Test connection briefly to validate credentials
|
|
1103
|
+
async with self.engine.connect() as _:
|
|
1104
|
+
pass # Connection test successful
|
|
1105
|
+
|
|
1106
|
+
# Don't store persistent connection
|
|
1107
|
+
self.connection = None
|
|
1108
|
+
|
|
1109
|
+
# conformance: ignore[E004] exc_info omitted intentionally; SQLAlchemy embeds password in traceback; cause is sanitized and re-raised as typed error
|
|
1110
|
+
except Exception as e:
|
|
1111
|
+
# No exc_info here: SQLAlchemy errors embed the full connection
|
|
1112
|
+
# string (including the password) in their message, and the
|
|
1113
|
+
# traceback would print it verbatim into logs.
|
|
1114
|
+
logger.error( # conformance: ignore[E005,L004,L009] exc_info would expose SQLAlchemy password; logs sanitized cause at failure site — raised typed error wraps only the raw unsanitized cause
|
|
1115
|
+
"Error establishing database connection: %s", sanitize_cause_repr(e)
|
|
1116
|
+
)
|
|
1117
|
+
if self.engine:
|
|
1118
|
+
await self.engine.dispose()
|
|
1119
|
+
self.engine = None
|
|
1120
|
+
if budget.expired():
|
|
1121
|
+
raise _load_budget_expired() from e
|
|
1122
|
+
# Already typed (a MissingSqlParamError from building the
|
|
1123
|
+
# connection string, a connector's own leaf): keep its verdict.
|
|
1124
|
+
if isinstance(e, AppError):
|
|
1125
|
+
raise
|
|
1126
|
+
raise _load_failure(e) from e
|
|
1127
|
+
|
|
1128
|
+
async def close(self) -> None:
|
|
1129
|
+
"""Close the async database connection and dispose of the engine."""
|
|
1130
|
+
if self.engine:
|
|
1131
|
+
await self.engine.dispose()
|
|
1132
|
+
self.engine = None
|
|
1133
|
+
self.connection = None
|
|
1134
|
+
|
|
1135
|
+
async def run_query(
|
|
1136
|
+
self,
|
|
1137
|
+
query: str,
|
|
1138
|
+
batch_size: int = 100000,
|
|
1139
|
+
params: Mapping[str, SqlBindValue] | None = None,
|
|
1140
|
+
):
|
|
1141
|
+
"""Execute a SQL query asynchronously and return results in batches using lazy connections.
|
|
1142
|
+
|
|
1143
|
+
This method creates an async connection on-demand, executes the query in batches,
|
|
1144
|
+
and automatically closes the connection when done. This prevents memory leaks
|
|
1145
|
+
from persistent connections.
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
query (str): SQL query to execute.
|
|
1149
|
+
batch_size (int, optional): Number of records to fetch in each batch.
|
|
1150
|
+
Defaults to 100000.
|
|
1151
|
+
params (Mapping[str, SqlBindValue], optional): Values to bind to ``:name``
|
|
1152
|
+
placeholders in ``query``, as for :meth:`BaseSQLClient.run_query`.
|
|
1153
|
+
|
|
1154
|
+
Yields:
|
|
1155
|
+
List[Dict[str, Any]]: Batches of query results, where each result is
|
|
1156
|
+
a dictionary mapping column names to values.
|
|
1157
|
+
|
|
1158
|
+
Raises:
|
|
1159
|
+
EngineNotInitializedError: If engine is not initialized.
|
|
1160
|
+
"""
|
|
1161
|
+
if not self.engine:
|
|
1162
|
+
raise EngineNotInitializedError()
|
|
1163
|
+
bound = _bound_params(query, params)
|
|
1164
|
+
|
|
1165
|
+
logger.debug(
|
|
1166
|
+
"Running query (sha=%s, len=%d, bound=%d)",
|
|
1167
|
+
hashlib.sha256(query.encode("utf-8", errors="replace")).hexdigest()[:16],
|
|
1168
|
+
len(query),
|
|
1169
|
+
len(bound or {}),
|
|
1170
|
+
)
|
|
1171
|
+
use_server_side_cursor = self.use_server_side_cursor
|
|
1172
|
+
|
|
1173
|
+
# Use async context manager for automatic connection cleanup
|
|
1174
|
+
async with self.engine.connect() as connection:
|
|
1175
|
+
try:
|
|
1176
|
+
from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
|
|
1177
|
+
|
|
1178
|
+
if use_server_side_cursor:
|
|
1179
|
+
connection = await connection.execution_options(
|
|
1180
|
+
yield_per=batch_size
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
statement = text(_escape_colons_for_text(query, frozenset(bound or ())))
|
|
1184
|
+
result = (
|
|
1185
|
+
await connection.stream(statement, bound)
|
|
1186
|
+
if use_server_side_cursor
|
|
1187
|
+
else await connection.execute(statement, bound)
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
column_names = list(result.keys())
|
|
1191
|
+
|
|
1192
|
+
while True:
|
|
1193
|
+
rows = (
|
|
1194
|
+
await result.fetchmany(batch_size)
|
|
1195
|
+
if use_server_side_cursor
|
|
1196
|
+
else result.cursor.fetchmany(batch_size)
|
|
1197
|
+
if result.cursor
|
|
1198
|
+
else None
|
|
1199
|
+
)
|
|
1200
|
+
if not rows:
|
|
1201
|
+
break
|
|
1202
|
+
yield [dict(zip(column_names, row)) for row in rows]
|
|
1203
|
+
|
|
1204
|
+
except AppError:
|
|
1205
|
+
raise
|
|
1206
|
+
# conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
# retryable=True: unclassified driver exception — do not override
|
|
1209
|
+
# the activity's declared retry policy. A transient source-side
|
|
1210
|
+
# condition (concurrent DDL, lock contention, dropped connection)
|
|
1211
|
+
# is indistinguishable from a permanent one here.
|
|
1212
|
+
# See SqlPandasResultError docstring.
|
|
1213
|
+
raise SqlPandasResultError(
|
|
1214
|
+
message="Error executing SQL query", cause=e, retryable=True
|
|
1215
|
+
) from e
|
|
1216
|
+
# Async connection automatically closed by context manager
|
|
1217
|
+
|
|
1218
|
+
logger.info("Query execution completed")
|