arrowbricks 0.1.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.
- arrowbricks/__init__.py +23 -0
- arrowbricks/_streaming.py +263 -0
- arrowbricks/client.py +374 -0
- arrowbricks/cursor.py +261 -0
- arrowbricks-0.1.0.dist-info/METADATA +114 -0
- arrowbricks-0.1.0.dist-info/RECORD +8 -0
- arrowbricks-0.1.0.dist-info/WHEEL +4 -0
- arrowbricks-0.1.0.dist-info/licenses/LICENSE +21 -0
arrowbricks/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ._streaming import (
|
|
2
|
+
HEARTBEAT,
|
|
3
|
+
QueryTimeout,
|
|
4
|
+
ReplayableArrowChunk,
|
|
5
|
+
await_with_heartbeat,
|
|
6
|
+
stream_query_json,
|
|
7
|
+
write_ipc_stream,
|
|
8
|
+
)
|
|
9
|
+
from .client import DatabricksClient
|
|
10
|
+
from .cursor import Connection, Cursor, connect
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"HEARTBEAT",
|
|
14
|
+
"Connection",
|
|
15
|
+
"Cursor",
|
|
16
|
+
"DatabricksClient",
|
|
17
|
+
"QueryTimeout",
|
|
18
|
+
"ReplayableArrowChunk",
|
|
19
|
+
"await_with_heartbeat",
|
|
20
|
+
"connect",
|
|
21
|
+
"stream_query_json",
|
|
22
|
+
"write_ipc_stream",
|
|
23
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Chunk fetching, heartbeats, and Arrow (de)serialization -- everything that
|
|
2
|
+
turns a DatabricksClient's raw chunk bytes into Arrow, via arro3 directly.
|
|
3
|
+
Single Arrow engine, no pluggable backend: arrowbricks' whole reason to exist
|
|
4
|
+
is "Databricks to Arrow via arro3", so there's nothing to make pluggable.
|
|
5
|
+
|
|
6
|
+
`write_ipc_stream` always passes `compression=None` -- arro3's own default
|
|
7
|
+
(`compression="LZ4"`) body-compresses every record batch, which DuckDB's own
|
|
8
|
+
Arrow C Data Interface reader decompresses transparently but which other
|
|
9
|
+
Arrow IPC readers may not support at all (observed: duckdb-wasm's browser-side
|
|
10
|
+
decoder silently fails to parse it). Plain, uncompressed bodies are the safe
|
|
11
|
+
default for a library whose bytes might end up read by anything."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import io
|
|
18
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
19
|
+
from typing import Any, BinaryIO, TypeVar
|
|
20
|
+
|
|
21
|
+
import arro3.core as core
|
|
22
|
+
import arro3.io as aio
|
|
23
|
+
|
|
24
|
+
from .client import DatabricksClient
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"HEARTBEAT",
|
|
28
|
+
"QueryTimeout",
|
|
29
|
+
"ReplayableArrowChunk",
|
|
30
|
+
"await_with_heartbeat",
|
|
31
|
+
"fetch_arrow_chunks_for_statement",
|
|
32
|
+
"fetch_arrow_chunks_with_manifest",
|
|
33
|
+
"stream_query_json",
|
|
34
|
+
"write_ipc_stream",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# How often a caller waiting on a slow Databricks round-trip (warehouse cold
|
|
38
|
+
# start, a long-running statement) gets a HEARTBEAT -- pick something well
|
|
39
|
+
# under whatever idle-connection ceiling sits between your server and its
|
|
40
|
+
# client (e.g. many PaaS load balancers cut an idle SSE connection around
|
|
41
|
+
# ~230s) if you're forwarding these as keep-alive pings.
|
|
42
|
+
_HEARTBEAT_INTERVAL_S = 15.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QueryTimeout(RuntimeError):
|
|
46
|
+
"""Raised when a query exceeds its `total_timeout_s`."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Heartbeat:
|
|
50
|
+
__slots__ = ()
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return "HEARTBEAT"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
HEARTBEAT = _Heartbeat()
|
|
57
|
+
|
|
58
|
+
T = TypeVar("T")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_ipc_stream(stream: Any, buf: BinaryIO) -> None:
|
|
62
|
+
"""Writes `stream` (anything implementing `__arrow_c_stream__`, e.g. an
|
|
63
|
+
arro3 Table/RecordBatchReader or a ReplayableArrowChunk) as Arrow-IPC
|
|
64
|
+
stream bytes -- always uncompressed, see module docstring."""
|
|
65
|
+
aio.write_ipc_stream(stream, buf, compression=None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReplayableArrowChunk:
|
|
69
|
+
"""Wraps one Arrow IPC-stream byte chunk so it can be handed to something
|
|
70
|
+
that calls `__arrow_c_stream__` more than once per relation (a schema
|
|
71
|
+
peek, then the actual scan -- DuckDB's registration path does this, for
|
|
72
|
+
one). A plain parsed stream is single-use and raises on the second call,
|
|
73
|
+
so this re-parses from the cached bytes every call instead. The bytes are
|
|
74
|
+
already fully in memory (just downloaded), so re-parsing costs a cheap
|
|
75
|
+
second pass, not a second network fetch."""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("_data", "chunk_index", "declared_row_count")
|
|
78
|
+
|
|
79
|
+
def __init__(self, data: bytes, chunk_index: int, declared_row_count: int | None = None) -> None:
|
|
80
|
+
self._data = data
|
|
81
|
+
self.chunk_index = chunk_index
|
|
82
|
+
self.declared_row_count = declared_row_count
|
|
83
|
+
|
|
84
|
+
def __arrow_c_stream__(self, requested_schema: object = None) -> object:
|
|
85
|
+
reader = aio.read_ipc_stream(io.BytesIO(self._data))
|
|
86
|
+
return reader.__arrow_c_stream__(requested_schema)
|
|
87
|
+
|
|
88
|
+
def nbytes(self) -> int:
|
|
89
|
+
return len(self._data)
|
|
90
|
+
|
|
91
|
+
def to_table(self) -> core.Table:
|
|
92
|
+
"""Parses this chunk's bytes into an arro3 Table -- the entry point
|
|
93
|
+
`cursor.py`'s result-set buffering uses to work with chunks at the
|
|
94
|
+
Arrow level (concat/slice) rather than re-parsing bytes per access."""
|
|
95
|
+
return core.Table.from_arrow(self)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def fetch_arrow_chunks_with_manifest(
|
|
99
|
+
client: DatabricksClient,
|
|
100
|
+
sql: str,
|
|
101
|
+
*,
|
|
102
|
+
catalog: str | None = None,
|
|
103
|
+
schema: str | None = None,
|
|
104
|
+
parameters: list[dict[str, Any]] | None = None,
|
|
105
|
+
) -> tuple[str, dict[str, Any], AsyncIterator[ReplayableArrowChunk]]:
|
|
106
|
+
"""Submits `sql` and returns (statement_id, manifest, chunk_iterator).
|
|
107
|
+
total_row_count/total_chunk_count are already in the manifest once the
|
|
108
|
+
statement succeeds, so callers needing a progress-bar target don't need a
|
|
109
|
+
separate preflight COUNT(*)."""
|
|
110
|
+
statement_id, manifest = await client.execute_arrow_statement(
|
|
111
|
+
sql, catalog=catalog, schema=schema, parameters=parameters
|
|
112
|
+
)
|
|
113
|
+
chunk_metas = manifest.get("chunks") or []
|
|
114
|
+
return statement_id, manifest, fetch_arrow_chunks_for_statement(client, statement_id, chunk_metas)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def fetch_arrow_chunks_for_statement(
|
|
118
|
+
client: DatabricksClient, statement_id: str, chunk_metas: list[dict[str, Any]]
|
|
119
|
+
) -> AsyncIterator[ReplayableArrowChunk]:
|
|
120
|
+
async for chunk_bytes, row_count, chunk_index in client.stream_chunks_by_index(statement_id, chunk_metas):
|
|
121
|
+
if chunk_bytes:
|
|
122
|
+
yield ReplayableArrowChunk(chunk_bytes, chunk_index, declared_row_count=row_count)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def await_with_heartbeat(
|
|
126
|
+
aw: Awaitable[T], *, interval_s: float = _HEARTBEAT_INTERVAL_S, total_timeout_s: float | None = None
|
|
127
|
+
) -> AsyncIterator[Any]:
|
|
128
|
+
"""Wraps a single slow awaitable with periodic HEARTBEAT yields, so a
|
|
129
|
+
caller streaming this over e.g. SSE never goes silent for the whole wait.
|
|
130
|
+
Yields HEARTBEAT zero or more times, then yields the awaitable's real
|
|
131
|
+
result exactly once. Re-raises whatever `aw` raised, or QueryTimeout if
|
|
132
|
+
`total_timeout_s` elapses first."""
|
|
133
|
+
task: asyncio.Task[T] = asyncio.ensure_future(aw)
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
deadline = loop.time() + total_timeout_s if total_timeout_s is not None else None
|
|
136
|
+
try:
|
|
137
|
+
while not task.done():
|
|
138
|
+
wait_for = interval_s if deadline is None else min(interval_s, max(deadline - loop.time(), 0.0))
|
|
139
|
+
done, _ = await asyncio.wait({task}, timeout=wait_for)
|
|
140
|
+
if not done:
|
|
141
|
+
if deadline is not None and loop.time() >= deadline:
|
|
142
|
+
task.cancel()
|
|
143
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
144
|
+
await task
|
|
145
|
+
raise QueryTimeout(f"Query exceeded {total_timeout_s}s timeout")
|
|
146
|
+
yield HEARTBEAT
|
|
147
|
+
yield await task
|
|
148
|
+
finally:
|
|
149
|
+
if not task.done():
|
|
150
|
+
task.cancel()
|
|
151
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
152
|
+
await task
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def heartbeat_over_stream(
|
|
156
|
+
aiter: AsyncIterator[T], *, interval_s: float = _HEARTBEAT_INTERVAL_S, total_timeout_s: float | None = None
|
|
157
|
+
) -> AsyncIterator[Any]:
|
|
158
|
+
"""Like await_with_heartbeat, but for a stream of items rather than one
|
|
159
|
+
final result: yields HEARTBEAT whenever the wait for the *next* item
|
|
160
|
+
exceeds interval_s, and otherwise passes each item through as it arrives.
|
|
161
|
+
Used by stream_query_json (and cursor.py's execute_streamed) so a slow
|
|
162
|
+
chunk mid-stream -- not just a cold warehouse start before the first one
|
|
163
|
+
-- never lets a downstream SSE connection go silent for the whole wait."""
|
|
164
|
+
loop = asyncio.get_running_loop()
|
|
165
|
+
deadline = loop.time() + total_timeout_s if total_timeout_s is not None else None
|
|
166
|
+
it = aiter.__aiter__()
|
|
167
|
+
task: asyncio.Task[Any] | None = None
|
|
168
|
+
try:
|
|
169
|
+
while True:
|
|
170
|
+
if task is None:
|
|
171
|
+
task = asyncio.ensure_future(it.__anext__())
|
|
172
|
+
wait_for = interval_s if deadline is None else min(interval_s, max(deadline - loop.time(), 0.0))
|
|
173
|
+
done, _ = await asyncio.wait({task}, timeout=wait_for)
|
|
174
|
+
if not done:
|
|
175
|
+
if deadline is not None and loop.time() >= deadline:
|
|
176
|
+
task.cancel()
|
|
177
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
178
|
+
await task
|
|
179
|
+
raise QueryTimeout(f"Query exceeded {total_timeout_s}s timeout")
|
|
180
|
+
yield HEARTBEAT
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
yield task.result()
|
|
184
|
+
except StopAsyncIteration:
|
|
185
|
+
return
|
|
186
|
+
finally:
|
|
187
|
+
task = None
|
|
188
|
+
finally:
|
|
189
|
+
if task is not None and not task.done():
|
|
190
|
+
task.cancel()
|
|
191
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
192
|
+
await task
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def windowed_sql(sql: str, *, row_limit: int | None, offset: int | None) -> str:
|
|
196
|
+
"""Pushes LIMIT/OFFSET into the SQL submitted to Databricks -- a query
|
|
197
|
+
should never fetch more rows from the warehouse than the caller wants."""
|
|
198
|
+
if row_limit is None and not offset:
|
|
199
|
+
return sql
|
|
200
|
+
if row_limit is None:
|
|
201
|
+
return f"SELECT * FROM ({sql}) _q OFFSET {offset}" # noqa: S608
|
|
202
|
+
if offset:
|
|
203
|
+
return f"SELECT * FROM ({sql}) _q LIMIT {row_limit} OFFSET {offset}" # noqa: S608
|
|
204
|
+
return f"SELECT * FROM ({sql}) _q LIMIT {row_limit}" # noqa: S608
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _write_ndjson(chunk: ReplayableArrowChunk) -> bytes:
|
|
208
|
+
buf = io.BytesIO()
|
|
209
|
+
# explicit_nulls=True: arro3 omits null-valued keys by default -- without
|
|
210
|
+
# this a row's JSON shape would vary by which columns happen to be null
|
|
211
|
+
# in it.
|
|
212
|
+
aio.write_ndjson(chunk, buf, explicit_nulls=True)
|
|
213
|
+
return buf.getvalue()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def stream_query_json(
|
|
217
|
+
client: DatabricksClient,
|
|
218
|
+
sql: str,
|
|
219
|
+
*,
|
|
220
|
+
params: list[dict[str, Any]] | None = None,
|
|
221
|
+
row_limit: int | None = None,
|
|
222
|
+
offset: int | None = None,
|
|
223
|
+
catalog: str | None = None,
|
|
224
|
+
schema: str | None = None,
|
|
225
|
+
total_timeout_s: float | None = None,
|
|
226
|
+
) -> AsyncIterator[Any]:
|
|
227
|
+
"""Yields HEARTBEAT while waiting on Databricks, then each result row as a
|
|
228
|
+
ready-to-send JSON string (arro3's native `write_ndjson`) -- one per SSE
|
|
229
|
+
frame, say.
|
|
230
|
+
|
|
231
|
+
Unlike cursor.py's fetch methods, this registers and emits each Databricks
|
|
232
|
+
chunk AS IT ARRIVES rather than buffering the full result first -- the
|
|
233
|
+
first row reaches the caller after ~one chunk's fetch time, not the whole
|
|
234
|
+
statement's, and at most a handful of chunks' bytes (bounded by the
|
|
235
|
+
client's own chunk_fetch_concurrency) are ever held in memory at once, not
|
|
236
|
+
O(whole result). Chunks can arrive out of order, so out-of-order arrivals
|
|
237
|
+
sit in a small `pending` buffer until the next expected chunk_index shows
|
|
238
|
+
up -- that buffer stays bounded by concurrency, it never grows to the full
|
|
239
|
+
result.
|
|
240
|
+
|
|
241
|
+
Note this yields a whole chunk's rows at once (write_ndjson has no
|
|
242
|
+
incremental/row-at-a-time mode) -- Databricks' own chunk sizing already
|
|
243
|
+
bounds how much that is, so the overall memory-bounded-across-chunks
|
|
244
|
+
guarantee above still holds, just at chunk granularity."""
|
|
245
|
+
sql = windowed_sql(sql, row_limit=row_limit, offset=offset)
|
|
246
|
+
_statement_id, _manifest, chunk_iter = await fetch_arrow_chunks_with_manifest(
|
|
247
|
+
client, sql, catalog=catalog, schema=schema, parameters=params
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
pending: dict[int, ReplayableArrowChunk] = {}
|
|
251
|
+
next_idx = 0
|
|
252
|
+
loop = asyncio.get_running_loop()
|
|
253
|
+
async for item in heartbeat_over_stream(chunk_iter, total_timeout_s=total_timeout_s):
|
|
254
|
+
if item is HEARTBEAT:
|
|
255
|
+
yield HEARTBEAT
|
|
256
|
+
continue
|
|
257
|
+
pending[item.chunk_index] = item
|
|
258
|
+
while next_idx in pending:
|
|
259
|
+
chunk = pending.pop(next_idx)
|
|
260
|
+
blob = await loop.run_in_executor(None, _write_ndjson, chunk)
|
|
261
|
+
for line in blob.splitlines():
|
|
262
|
+
yield line.decode()
|
|
263
|
+
next_idx += 1
|
arrowbricks/client.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Async client for the Databricks SQL Statement Execution API, tuned for
|
|
2
|
+
pulling large results out as Arrow-IPC chunks rather than running a single
|
|
3
|
+
small query. No dependency on Arrow at all -- that lives in `_streaming.py`
|
|
4
|
+
and `cursor.py`, so this client is reusable by any caller regardless of what
|
|
5
|
+
they do with the raw chunk bytes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from typing import Any, TypeVar, cast
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
_POLL_INTERVAL_S = 2.0
|
|
19
|
+
_TERMINAL_STATES = {"SUCCEEDED", "FAILED", "CANCELED", "CLOSED"}
|
|
20
|
+
_RETRY_ATTEMPTS = 6
|
|
21
|
+
_RETRY_MAX_WAIT_S = 20.0
|
|
22
|
+
|
|
23
|
+
TokenProvider = Callable[[], "str | Awaitable[str]"]
|
|
24
|
+
|
|
25
|
+
_T = TypeVar("_T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_transient_error(exc: BaseException) -> bool:
|
|
29
|
+
"""401/403/429/5xx are treated as transient and retried -- these have been
|
|
30
|
+
observed in practice to resolve themselves (e.g. a just-granted permission
|
|
31
|
+
or a just-refreshed token not yet visible to the request that raced it)
|
|
32
|
+
rather than indicating a persistent problem. httpx.TimeoutException/
|
|
33
|
+
NetworkError are deliberately excluded: a genuinely stalled connection
|
|
34
|
+
should fail fast on the caller's own timeout, not be retried here."""
|
|
35
|
+
return isinstance(exc, httpx.HTTPStatusError) and (
|
|
36
|
+
exc.response.status_code in (401, 403, 408, 429) or exc.response.status_code >= 500
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _retry_call(fn: Callable[[], Awaitable[_T]]) -> _T:
|
|
41
|
+
"""Retry fn() with exponential backoff (1s, 2s, 4s, ... capped at
|
|
42
|
+
_RETRY_MAX_WAIT_S) on transient errors, up to _RETRY_ATTEMPTS total tries.
|
|
43
|
+
A hand-rolled loop instead of tenacity -- this is the only retry pattern
|
|
44
|
+
in the whole client, so a dependency for it isn't worth it."""
|
|
45
|
+
attempt = 0
|
|
46
|
+
while True:
|
|
47
|
+
try:
|
|
48
|
+
return await fn()
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
if attempt == _RETRY_ATTEMPTS - 1 or not _is_transient_error(exc):
|
|
51
|
+
raise
|
|
52
|
+
await asyncio.sleep(min(_RETRY_MAX_WAIT_S, 2.0**attempt))
|
|
53
|
+
attempt += 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _raise_for_failed(status: dict[str, Any]) -> None:
|
|
57
|
+
state = status.get("state")
|
|
58
|
+
if state == "FAILED":
|
|
59
|
+
err = status.get("error", {})
|
|
60
|
+
raise RuntimeError(f"Databricks statement failed [{err.get('error_code')}]: {err.get('message')}")
|
|
61
|
+
if state == "CANCELED":
|
|
62
|
+
raise RuntimeError("Databricks statement was canceled")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DatabricksClient:
|
|
66
|
+
"""One Databricks SQL warehouse endpoint. Auth is entirely bring-your-own:
|
|
67
|
+
pass a static `token`, or a `token_provider` callable (sync or async) that
|
|
68
|
+
returns one -- this client has no opinion on *how* you get a token (a
|
|
69
|
+
personal access token, an OAuth M2M flow, a cloud-provider credential
|
|
70
|
+
chain) and no cloud-SDK dependency baked in. If your provider is expensive
|
|
71
|
+
to call (e.g. shells out to a CLI), cache/refresh inside it -- this client
|
|
72
|
+
calls it on every request and does no caching of its own."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
host: str,
|
|
77
|
+
warehouse_id: str,
|
|
78
|
+
*,
|
|
79
|
+
token: str | None = None,
|
|
80
|
+
token_provider: TokenProvider | None = None,
|
|
81
|
+
http_timeout: float = 60.0,
|
|
82
|
+
wait_timeout: str = "30s",
|
|
83
|
+
chunk_fetch_concurrency: int = 6,
|
|
84
|
+
warehouse_start_timeout: float = 300.0,
|
|
85
|
+
) -> None:
|
|
86
|
+
if not token and not token_provider:
|
|
87
|
+
raise ValueError("DatabricksClient needs either `token` or `token_provider`")
|
|
88
|
+
self._host = host.rstrip("/")
|
|
89
|
+
if not self._host.startswith("https://"):
|
|
90
|
+
self._host = f"https://{self._host}"
|
|
91
|
+
self.warehouse_id = warehouse_id
|
|
92
|
+
self._token = token
|
|
93
|
+
self._token_provider = token_provider
|
|
94
|
+
self.http_timeout = http_timeout
|
|
95
|
+
self.wait_timeout = wait_timeout
|
|
96
|
+
# Each concurrent fetch slot (plus its matching queued-but-unconsumed
|
|
97
|
+
# slot, see _fetch_chunks_with_backpressure) holds a whole chunk's raw
|
|
98
|
+
# bytes in plain Python memory. Keep this modest by default -- it's
|
|
99
|
+
# I/O-bound network fetching, so parallelism past single digits buys
|
|
100
|
+
# little, while every extra slot is more memory held hostage behind a
|
|
101
|
+
# slow consumer.
|
|
102
|
+
self.chunk_fetch_concurrency = chunk_fetch_concurrency
|
|
103
|
+
self.warehouse_start_timeout = warehouse_start_timeout
|
|
104
|
+
|
|
105
|
+
async def _bearer_token(self) -> str:
|
|
106
|
+
if self._token is not None:
|
|
107
|
+
return self._token
|
|
108
|
+
assert self._token_provider is not None # noqa: S101 -- enforced in __init__, not a test assertion
|
|
109
|
+
result = self._token_provider()
|
|
110
|
+
if inspect.isawaitable(result):
|
|
111
|
+
return await cast("Awaitable[str]", result)
|
|
112
|
+
return cast(str, result)
|
|
113
|
+
|
|
114
|
+
async def _headers(self, content_type: str = "application/json") -> dict[str, str]:
|
|
115
|
+
return {
|
|
116
|
+
"Authorization": f"Bearer {await self._bearer_token()}",
|
|
117
|
+
"Content-Type": content_type,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async def _authed_request(
|
|
121
|
+
self, client: httpx.AsyncClient, method: str, url: str, *, content_type: str = "application/json", **kwargs: Any
|
|
122
|
+
) -> httpx.Response:
|
|
123
|
+
async def _do() -> httpx.Response:
|
|
124
|
+
resp = await client.request(
|
|
125
|
+
method, url, headers=await self._headers(content_type), timeout=self.http_timeout, **kwargs
|
|
126
|
+
)
|
|
127
|
+
resp.raise_for_status()
|
|
128
|
+
return resp
|
|
129
|
+
|
|
130
|
+
return await _retry_call(_do)
|
|
131
|
+
|
|
132
|
+
async def _ensure_warehouse_running(self, client: httpx.AsyncClient) -> None:
|
|
133
|
+
"""Explicitly wait for the warehouse to reach RUNNING before submitting
|
|
134
|
+
a statement, rather than relying on the Statement Execution API's own
|
|
135
|
+
implicit auto-start. A cold warehouse's catalog credential cache needs
|
|
136
|
+
a moment to catch up right after startup -- submitting straight into
|
|
137
|
+
that window is a common source of transient, identity-scoped 403s.
|
|
138
|
+
Fast path: a single GET when already RUNNING, so this adds no
|
|
139
|
+
meaningful overhead once warm."""
|
|
140
|
+
url = f"{self._host}/api/2.0/sql/warehouses/{self.warehouse_id}"
|
|
141
|
+
resp = await self._authed_request(client, "GET", url)
|
|
142
|
+
state = resp.json().get("state")
|
|
143
|
+
if state == "RUNNING":
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if state == "STOPPED":
|
|
147
|
+
await self._authed_request(client, "POST", f"{url}/start")
|
|
148
|
+
|
|
149
|
+
deadline = time.monotonic() + self.warehouse_start_timeout
|
|
150
|
+
while time.monotonic() < deadline:
|
|
151
|
+
await asyncio.sleep(_POLL_INTERVAL_S)
|
|
152
|
+
resp = await self._authed_request(client, "GET", url)
|
|
153
|
+
state = resp.json().get("state")
|
|
154
|
+
if state == "RUNNING":
|
|
155
|
+
return
|
|
156
|
+
# Falls through and lets the statement submission itself surface
|
|
157
|
+
# whatever's actually wrong -- proceeding anyway rather than raising
|
|
158
|
+
# here avoids a false negative if the warehouse comes up moments later.
|
|
159
|
+
|
|
160
|
+
async def _execute_statement(
|
|
161
|
+
self,
|
|
162
|
+
statement: str,
|
|
163
|
+
*,
|
|
164
|
+
format: str,
|
|
165
|
+
catalog: str | None,
|
|
166
|
+
schema: str | None,
|
|
167
|
+
wait_timeout: str | None,
|
|
168
|
+
parameters: list[dict[str, Any]] | None,
|
|
169
|
+
) -> tuple[str, dict[str, Any]]:
|
|
170
|
+
"""Submit an EXTERNAL_LINKS statement in the given result format and
|
|
171
|
+
poll until terminal. Returns (statement_id, manifest). `parameters`,
|
|
172
|
+
if given, is Databricks' own named-parameter format --
|
|
173
|
+
[{"name": ..., "value": ..., "type": ...}] bound against `:name`
|
|
174
|
+
markers in `statement`."""
|
|
175
|
+
body: dict[str, Any] = {
|
|
176
|
+
"warehouse_id": self.warehouse_id,
|
|
177
|
+
"statement": statement,
|
|
178
|
+
"disposition": "EXTERNAL_LINKS",
|
|
179
|
+
"format": format,
|
|
180
|
+
"wait_timeout": wait_timeout or self.wait_timeout,
|
|
181
|
+
"on_wait_timeout": "CONTINUE",
|
|
182
|
+
}
|
|
183
|
+
if catalog:
|
|
184
|
+
body["catalog"] = catalog
|
|
185
|
+
if schema:
|
|
186
|
+
body["schema"] = schema
|
|
187
|
+
if parameters:
|
|
188
|
+
body["parameters"] = parameters
|
|
189
|
+
|
|
190
|
+
async with httpx.AsyncClient() as client:
|
|
191
|
+
await self._ensure_warehouse_running(client)
|
|
192
|
+
resp = await self._authed_request(client, "POST", f"{self._host}/api/2.0/sql/statements", json=body)
|
|
193
|
+
data = resp.json()
|
|
194
|
+
|
|
195
|
+
status = data.get("status", {})
|
|
196
|
+
while status.get("state") not in _TERMINAL_STATES:
|
|
197
|
+
statement_id = data["statement_id"]
|
|
198
|
+
await asyncio.sleep(_POLL_INTERVAL_S)
|
|
199
|
+
resp = await self._authed_request(client, "GET", f"{self._host}/api/2.0/sql/statements/{statement_id}")
|
|
200
|
+
data = resp.json()
|
|
201
|
+
status = data.get("status", {})
|
|
202
|
+
|
|
203
|
+
_raise_for_failed(status)
|
|
204
|
+
return data["statement_id"], data.get("manifest") or {}
|
|
205
|
+
|
|
206
|
+
async def execute_arrow_statement(
|
|
207
|
+
self,
|
|
208
|
+
statement: str,
|
|
209
|
+
*,
|
|
210
|
+
catalog: str | None = None,
|
|
211
|
+
schema: str | None = None,
|
|
212
|
+
wait_timeout: str | None = None,
|
|
213
|
+
parameters: list[dict[str, Any]] | None = None,
|
|
214
|
+
) -> tuple[str, dict[str, Any]]:
|
|
215
|
+
"""Like _execute_statement, fixed to ARROW_STREAM -- for callers that
|
|
216
|
+
ingest the result as Arrow (see _streaming.py/cursor.py)."""
|
|
217
|
+
return await self._execute_statement(
|
|
218
|
+
statement,
|
|
219
|
+
format="ARROW_STREAM",
|
|
220
|
+
catalog=catalog,
|
|
221
|
+
schema=schema,
|
|
222
|
+
wait_timeout=wait_timeout,
|
|
223
|
+
parameters=parameters,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def execute_json_statement(
|
|
227
|
+
self,
|
|
228
|
+
statement: str,
|
|
229
|
+
*,
|
|
230
|
+
catalog: str | None = None,
|
|
231
|
+
schema: str | None = None,
|
|
232
|
+
wait_timeout: str | None = None,
|
|
233
|
+
parameters: list[dict[str, Any]] | None = None,
|
|
234
|
+
) -> tuple[str, dict[str, Any]]:
|
|
235
|
+
"""Like _execute_statement, fixed to JSON_ARRAY -- each fetched
|
|
236
|
+
chunk's bytes (see stream_chunks_by_index) are then a plain JSON array
|
|
237
|
+
of rows, for callers that want Python values without an Arrow parse."""
|
|
238
|
+
return await self._execute_statement(
|
|
239
|
+
statement,
|
|
240
|
+
format="JSON_ARRAY",
|
|
241
|
+
catalog=catalog,
|
|
242
|
+
schema=schema,
|
|
243
|
+
wait_timeout=wait_timeout,
|
|
244
|
+
parameters=parameters,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
async def upload_volume_file(self, volume_path: str, data: bytes) -> None:
|
|
248
|
+
"""Uploads `data` to a Unity Catalog volume path via the Files API,
|
|
249
|
+
overwriting anything already there. `volume_path` is caller-supplied
|
|
250
|
+
in full (e.g. `/Volumes/my_catalog/my_schema/my_volume/some/file.parquet`)
|
|
251
|
+
-- this package has no knowledge of any specific catalog/schema/volume."""
|
|
252
|
+
async with httpx.AsyncClient() as client:
|
|
253
|
+
await self._authed_request(
|
|
254
|
+
client,
|
|
255
|
+
"PUT",
|
|
256
|
+
f"{self._host}/api/2.0/fs/files{volume_path}",
|
|
257
|
+
params={"overwrite": "true"},
|
|
258
|
+
content_type="application/octet-stream",
|
|
259
|
+
content=data,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def delete_volume_file(self, volume_path: str) -> None:
|
|
263
|
+
"""Deletes a file at `volume_path` (see upload_volume_file). A 404 is
|
|
264
|
+
treated as success -- the file is already gone, which is fine for
|
|
265
|
+
idempotent staging cleanup."""
|
|
266
|
+
async with httpx.AsyncClient() as client:
|
|
267
|
+
try:
|
|
268
|
+
await self._authed_request(client, "DELETE", f"{self._host}/api/2.0/fs/files{volume_path}")
|
|
269
|
+
except httpx.HTTPStatusError as exc:
|
|
270
|
+
if exc.response.status_code != 404:
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
async def stream_chunks_by_index(
|
|
274
|
+
self, statement_id: str, chunk_metas: list[dict[str, Any]]
|
|
275
|
+
) -> AsyncIterator[tuple[bytes, int | None, int]]:
|
|
276
|
+
"""Fetch already-known chunks for a completed statement (see
|
|
277
|
+
execute_arrow_statement/execute_json_statement), with real
|
|
278
|
+
backpressure -- see _fetch_chunks_with_backpressure. Each yielded
|
|
279
|
+
(blob, row_count, chunk_index) is an independent, self-contained
|
|
280
|
+
result blob plus its row count from the manifest (`None` if the
|
|
281
|
+
manifest didn't carry one) and its own chunk_index, so a caller that
|
|
282
|
+
cares about the original row order (e.g. a query with ORDER BY) can
|
|
283
|
+
restore it even though chunks can complete out of order."""
|
|
284
|
+
async with httpx.AsyncClient() as client:
|
|
285
|
+
async for blob, row_count, chunk_index in self._fetch_chunks_with_backpressure(
|
|
286
|
+
client, statement_id, chunk_metas
|
|
287
|
+
):
|
|
288
|
+
yield blob, row_count, chunk_index
|
|
289
|
+
|
|
290
|
+
async def _fetch_link_bytes(self, client: httpx.AsyncClient, url: str) -> bytes:
|
|
291
|
+
async def _do() -> bytes:
|
|
292
|
+
resp = await client.get(url, timeout=self.http_timeout)
|
|
293
|
+
resp.raise_for_status()
|
|
294
|
+
return resp.content
|
|
295
|
+
|
|
296
|
+
return await _retry_call(_do)
|
|
297
|
+
|
|
298
|
+
async def _fetch_chunk_index(self, client: httpx.AsyncClient, statement_id: str, chunk_index: int) -> list[bytes]:
|
|
299
|
+
"""Resolve one chunk index to its external link(s) (usually exactly
|
|
300
|
+
one) and download the bytes. Each chunk index can be requested
|
|
301
|
+
independently -- that's what makes concurrent fetching possible."""
|
|
302
|
+
resp = await self._authed_request(
|
|
303
|
+
client, "GET", f"{self._host}/api/2.0/sql/statements/{statement_id}/result/chunks/{chunk_index}"
|
|
304
|
+
)
|
|
305
|
+
links = resp.json().get("external_links") or []
|
|
306
|
+
return await asyncio.gather(*(self._fetch_link_bytes(client, link["external_link"]) for link in links))
|
|
307
|
+
|
|
308
|
+
async def _fetch_chunks_with_backpressure(
|
|
309
|
+
self,
|
|
310
|
+
client: httpx.AsyncClient,
|
|
311
|
+
statement_id: str,
|
|
312
|
+
chunk_metas: list[dict[str, Any]],
|
|
313
|
+
) -> AsyncIterator[tuple[bytes, int | None, int]]:
|
|
314
|
+
"""Fetch every chunk concurrently (bounded), instead of one round trip
|
|
315
|
+
at a time -- for a result with hundreds of chunks, that sequential
|
|
316
|
+
chain dominates total latency. Chunks complete out of order; ordering
|
|
317
|
+
(if the caller's SQL needs it) is restored downstream, not here.
|
|
318
|
+
|
|
319
|
+
This is a bounded *worker pool*, not `asyncio.as_completed` over every
|
|
320
|
+
chunk task scheduled up front -- that shape bounds how many downloads
|
|
321
|
+
run *simultaneously* but not how many completed-and-downloaded chunks
|
|
322
|
+
pile up waiting for a slow consumer, since a slot frees the instant
|
|
323
|
+
its own download finishes, whether or not anything downstream has
|
|
324
|
+
consumed that chunk yet. If the consumer is slower than the network --
|
|
325
|
+
plausible, one async task processing vs. several concurrent
|
|
326
|
+
downloads -- unconsumed bytes still pile up to O(whole result)
|
|
327
|
+
regardless of the concurrency bound. Here, a worker blocks on
|
|
328
|
+
`out.put()` (a bounded queue) until the consumer actually takes the
|
|
329
|
+
previous chunk, so a fetch slot is only freed once its chunk has been
|
|
330
|
+
handed off -- backpressure reaches all the way to the download, and
|
|
331
|
+
peak resident chunks stay at ~concurrency + queue size, not
|
|
332
|
+
O(whole result)."""
|
|
333
|
+
concurrency = self.chunk_fetch_concurrency
|
|
334
|
+
work: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
335
|
+
for meta in chunk_metas:
|
|
336
|
+
work.put_nowait(meta)
|
|
337
|
+
out: asyncio.Queue[tuple[bytes, int | None, int] | None] = asyncio.Queue(maxsize=concurrency)
|
|
338
|
+
|
|
339
|
+
async def worker() -> None:
|
|
340
|
+
while True:
|
|
341
|
+
try:
|
|
342
|
+
meta = work.get_nowait()
|
|
343
|
+
except asyncio.QueueEmpty:
|
|
344
|
+
return
|
|
345
|
+
for blob in await self._fetch_chunk_index(client, statement_id, meta["chunk_index"]):
|
|
346
|
+
await out.put((blob, meta.get("row_count"), meta["chunk_index"])) # blocks here = backpressure
|
|
347
|
+
|
|
348
|
+
workers = [asyncio.create_task(worker()) for _ in range(min(concurrency, len(chunk_metas) or 1))]
|
|
349
|
+
|
|
350
|
+
async def close_when_done() -> None:
|
|
351
|
+
# return_exceptions=True, not the default False: with the default,
|
|
352
|
+
# a failing worker makes gather raise *immediately* and this
|
|
353
|
+
# coroutine never reaches `out.put(None)` -- the consumer's
|
|
354
|
+
# `await out.get()` below then blocks forever. Collect every
|
|
355
|
+
# worker's outcome first, unblock the consumer unconditionally,
|
|
356
|
+
# then re-raise whichever failed.
|
|
357
|
+
results = await asyncio.gather(*workers, return_exceptions=True)
|
|
358
|
+
await out.put(None)
|
|
359
|
+
for result in results:
|
|
360
|
+
if isinstance(result, BaseException):
|
|
361
|
+
raise result
|
|
362
|
+
|
|
363
|
+
closer = asyncio.create_task(close_when_done())
|
|
364
|
+
try:
|
|
365
|
+
while True:
|
|
366
|
+
item = await out.get()
|
|
367
|
+
if item is None:
|
|
368
|
+
break
|
|
369
|
+
yield item
|
|
370
|
+
await closer # propagate a worker failure that only surfaced after the last successful yield
|
|
371
|
+
finally:
|
|
372
|
+
closer.cancel()
|
|
373
|
+
for w in workers:
|
|
374
|
+
w.cancel()
|
arrowbricks/cursor.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""A Cursor/Connection surface shaped like databricks-sql-python's (execute,
|
|
2
|
+
fetchone/fetchmany/fetchall, fetchall_arrow/fetchmany_arrow) -- familiar DB-API
|
|
3
|
+
ergonomics on top of the Statement Execution API + arro3, for callers who want
|
|
4
|
+
to pull rows/Arrow incrementally rather than get everything back at once.
|
|
5
|
+
|
|
6
|
+
Unlike a real DB-API cursor, this is async throughout (`execute`, `fetchone`,
|
|
7
|
+
etc. are all coroutines) since the underlying client is -- there's no
|
|
8
|
+
synchronous escape hatch, matching every other function in this package.
|
|
9
|
+
|
|
10
|
+
Chunks are fetched from Databricks lazily, only as fetchone/fetchmany/fetchall
|
|
11
|
+
actually need more rows -- a `fetchmany(100)` loop over a 700k-row result
|
|
12
|
+
never pulls more chunks than it's consumed. Chunks can arrive out of order
|
|
13
|
+
over the network (see client.py); a `pending` buffer holds early arrivals
|
|
14
|
+
until the next expected chunk_index shows up, same reasoning as
|
|
15
|
+
_streaming.py's stream_query_json."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import arro3.core as core
|
|
23
|
+
|
|
24
|
+
from ._streaming import (
|
|
25
|
+
HEARTBEAT,
|
|
26
|
+
ReplayableArrowChunk,
|
|
27
|
+
await_with_heartbeat,
|
|
28
|
+
fetch_arrow_chunks_with_manifest,
|
|
29
|
+
windowed_sql,
|
|
30
|
+
)
|
|
31
|
+
from .client import DatabricksClient
|
|
32
|
+
|
|
33
|
+
__all__ = ["Connection", "Cursor", "connect"]
|
|
34
|
+
|
|
35
|
+
Row = tuple[Any, ...]
|
|
36
|
+
Description = tuple[str, str | None, None, None, None, None, None]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _empty_table(schema: core.Schema | None) -> core.Table:
|
|
40
|
+
return core.Table.from_batches([], schema=schema) if schema is not None else core.Table.from_pydict({})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _description_from_manifest(manifest: dict[str, Any]) -> list[Description]:
|
|
44
|
+
columns = manifest.get("schema", {}).get("columns") or []
|
|
45
|
+
return [(c.get("name"), c.get("type_name"), None, None, None, None, None) for c in columns]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ResultSet:
|
|
49
|
+
"""Buffers already-fetched-but-not-yet-returned rows at the Arrow level
|
|
50
|
+
(an arro3 Table), so fetchall_arrow/fetchmany_arrow stay zero-copy and
|
|
51
|
+
fetchone/fetchmany/fetchall just materialize whatever slice they need.
|
|
52
|
+
Not part of the public API -- reached only via Cursor."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, schema: core.Schema | None, chunk_aiter: AsyncIterator[ReplayableArrowChunk]) -> None:
|
|
55
|
+
self.schema = schema
|
|
56
|
+
self._chunk_aiter = chunk_aiter
|
|
57
|
+
self._pending: dict[int, ReplayableArrowChunk] = {}
|
|
58
|
+
self._next_idx = 0
|
|
59
|
+
self._exhausted = False
|
|
60
|
+
self._buffer: core.Table | None = None
|
|
61
|
+
self.rownumber = 0
|
|
62
|
+
|
|
63
|
+
async def _pull_one_chunk_table(self) -> core.Table | None:
|
|
64
|
+
"""Returns the next expected chunk's Table, in order -- checking
|
|
65
|
+
`_pending` FIRST, since a single earlier call can have pulled several
|
|
66
|
+
chunks off `_chunk_aiter` before the one it actually needed showed up
|
|
67
|
+
(arrival order is completion order, not chunk_index order), leaving
|
|
68
|
+
the rest already-fetched-and-buffered here. Only touches the network
|
|
69
|
+
(`_chunk_aiter.__anext__()`) once `_pending` has nothing more to give."""
|
|
70
|
+
while True:
|
|
71
|
+
if self._next_idx in self._pending:
|
|
72
|
+
ready = self._pending.pop(self._next_idx)
|
|
73
|
+
self._next_idx += 1
|
|
74
|
+
return ready.to_table()
|
|
75
|
+
if self._exhausted:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
chunk = await self._chunk_aiter.__anext__()
|
|
79
|
+
except StopAsyncIteration:
|
|
80
|
+
self._exhausted = True
|
|
81
|
+
continue
|
|
82
|
+
self._pending[chunk.chunk_index] = chunk
|
|
83
|
+
|
|
84
|
+
async def _ensure_buffer(self, want: int) -> None:
|
|
85
|
+
while (self._buffer is None or self._buffer.num_rows < want) and not self._exhausted:
|
|
86
|
+
table = await self._pull_one_chunk_table()
|
|
87
|
+
if table is None:
|
|
88
|
+
break
|
|
89
|
+
if self.schema is None:
|
|
90
|
+
self.schema = table.schema
|
|
91
|
+
self._buffer = (
|
|
92
|
+
table
|
|
93
|
+
if self._buffer is None
|
|
94
|
+
else core.Table.from_batches(self._buffer.to_batches() + table.to_batches(), schema=self._buffer.schema)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def fetchmany_arrow(self, size: int) -> core.Table:
|
|
98
|
+
await self._ensure_buffer(size)
|
|
99
|
+
if self._buffer is None or self._buffer.num_rows == 0:
|
|
100
|
+
return _empty_table(self.schema)
|
|
101
|
+
n = min(size, self._buffer.num_rows)
|
|
102
|
+
out = self._buffer.slice(0, n)
|
|
103
|
+
rest = self._buffer.slice(n)
|
|
104
|
+
self._buffer = rest if rest.num_rows else None
|
|
105
|
+
self.rownumber += n
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
async def fetchall_arrow(self) -> core.Table:
|
|
109
|
+
while not self._exhausted:
|
|
110
|
+
await self._ensure_buffer((self._buffer.num_rows if self._buffer is not None else 0) + 1)
|
|
111
|
+
out = self._buffer if self._buffer is not None else _empty_table(self.schema)
|
|
112
|
+
self._buffer = None
|
|
113
|
+
self.rownumber += out.num_rows
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _table_to_rows(table: core.Table) -> list[Row]:
|
|
118
|
+
if table.num_rows == 0:
|
|
119
|
+
return []
|
|
120
|
+
columns = [table.column(i).combine_chunks().to_pylist() for i in range(table.num_columns)]
|
|
121
|
+
return list(zip(*columns, strict=True))
|
|
122
|
+
|
|
123
|
+
async def fetchone(self) -> Row | None:
|
|
124
|
+
rows = self._table_to_rows(await self.fetchmany_arrow(1))
|
|
125
|
+
return rows[0] if rows else None
|
|
126
|
+
|
|
127
|
+
async def fetchmany(self, size: int) -> list[Row]:
|
|
128
|
+
return self._table_to_rows(await self.fetchmany_arrow(size))
|
|
129
|
+
|
|
130
|
+
async def fetchall(self) -> list[Row]:
|
|
131
|
+
return self._table_to_rows(await self.fetchall_arrow())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Cursor:
|
|
135
|
+
"""One statement's worth of state: `execute()` (or `execute_streamed()`)
|
|
136
|
+
submits and waits for it, then fetchone/fetchmany/fetchall/fetchall_arrow/
|
|
137
|
+
fetchmany_arrow pull its result. Reusable across statements -- each
|
|
138
|
+
`execute()` call replaces the previous result set."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, client: DatabricksClient) -> None:
|
|
141
|
+
self._client = client
|
|
142
|
+
self._result: _ResultSet | None = None
|
|
143
|
+
self.description: list[Description] | None = None
|
|
144
|
+
|
|
145
|
+
def execute_streamed(
|
|
146
|
+
self,
|
|
147
|
+
sql: str,
|
|
148
|
+
parameters: list[dict[str, Any]] | None = None,
|
|
149
|
+
*,
|
|
150
|
+
row_limit: int | None = None,
|
|
151
|
+
offset: int | None = None,
|
|
152
|
+
catalog: str | None = None,
|
|
153
|
+
schema: str | None = None,
|
|
154
|
+
total_timeout_s: float | None = None,
|
|
155
|
+
) -> AsyncIterator[Any]:
|
|
156
|
+
"""Like execute(), but yields HEARTBEAT while waiting on Databricks
|
|
157
|
+
instead of blocking silently -- for bridging e.g. an SSE connection
|
|
158
|
+
during a possible multi-minute cold warehouse start. Yields HEARTBEAT
|
|
159
|
+
zero or more times, then this same Cursor once ready to fetch."""
|
|
160
|
+
sql = windowed_sql(sql, row_limit=row_limit, offset=offset)
|
|
161
|
+
|
|
162
|
+
async def _gen() -> AsyncIterator[Any]:
|
|
163
|
+
fetch = fetch_arrow_chunks_with_manifest(
|
|
164
|
+
self._client, sql, catalog=catalog, schema=schema, parameters=parameters
|
|
165
|
+
)
|
|
166
|
+
async for item in await_with_heartbeat(fetch, total_timeout_s=total_timeout_s):
|
|
167
|
+
if item is HEARTBEAT:
|
|
168
|
+
yield HEARTBEAT
|
|
169
|
+
continue
|
|
170
|
+
_statement_id, manifest, chunk_iter = item
|
|
171
|
+
self.description = _description_from_manifest(manifest)
|
|
172
|
+
self._result = _ResultSet(schema=None, chunk_aiter=chunk_iter.__aiter__())
|
|
173
|
+
yield self
|
|
174
|
+
|
|
175
|
+
return _gen()
|
|
176
|
+
|
|
177
|
+
async def execute(
|
|
178
|
+
self,
|
|
179
|
+
sql: str,
|
|
180
|
+
parameters: list[dict[str, Any]] | None = None,
|
|
181
|
+
*,
|
|
182
|
+
row_limit: int | None = None,
|
|
183
|
+
offset: int | None = None,
|
|
184
|
+
catalog: str | None = None,
|
|
185
|
+
schema: str | None = None,
|
|
186
|
+
total_timeout_s: float | None = None,
|
|
187
|
+
) -> Cursor:
|
|
188
|
+
"""Submits `sql` and waits for it to complete -- a plain blocking
|
|
189
|
+
await, like a real DB-API cursor's execute(). `parameters`, if given,
|
|
190
|
+
is Databricks' own named-parameter format --
|
|
191
|
+
[{"name": ..., "value": ..., "type": ...}] bound against `:name`
|
|
192
|
+
markers in `sql`, not DB-API's `?`/`%s` placeholder style."""
|
|
193
|
+
async for _ in self.execute_streamed(
|
|
194
|
+
sql,
|
|
195
|
+
parameters,
|
|
196
|
+
row_limit=row_limit,
|
|
197
|
+
offset=offset,
|
|
198
|
+
catalog=catalog,
|
|
199
|
+
schema=schema,
|
|
200
|
+
total_timeout_s=total_timeout_s,
|
|
201
|
+
):
|
|
202
|
+
pass
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def _require_result(self) -> _ResultSet:
|
|
206
|
+
if self._result is None:
|
|
207
|
+
raise RuntimeError("no active result set -- call execute() first")
|
|
208
|
+
return self._result
|
|
209
|
+
|
|
210
|
+
async def fetchone(self) -> Row | None:
|
|
211
|
+
return await self._require_result().fetchone()
|
|
212
|
+
|
|
213
|
+
async def fetchmany(self, size: int) -> list[Row]:
|
|
214
|
+
return await self._require_result().fetchmany(size)
|
|
215
|
+
|
|
216
|
+
async def fetchall(self) -> list[Row]:
|
|
217
|
+
return await self._require_result().fetchall()
|
|
218
|
+
|
|
219
|
+
async def fetchmany_arrow(self, size: int) -> core.Table:
|
|
220
|
+
return await self._require_result().fetchmany_arrow(size)
|
|
221
|
+
|
|
222
|
+
async def fetchall_arrow(self) -> core.Table:
|
|
223
|
+
return await self._require_result().fetchall_arrow()
|
|
224
|
+
|
|
225
|
+
def __aiter__(self) -> Cursor:
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
async def __anext__(self) -> Row:
|
|
229
|
+
row = await self.fetchone()
|
|
230
|
+
if row is None:
|
|
231
|
+
raise StopAsyncIteration
|
|
232
|
+
return row
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Connection:
|
|
236
|
+
"""One Databricks SQL warehouse endpoint -- see DatabricksClient for the
|
|
237
|
+
constructor args (auth, timeouts, chunk_fetch_concurrency). Statements are
|
|
238
|
+
independent REST calls with no server-side session state, so `cursor()`
|
|
239
|
+
can be called as many times as you like; `close()` is a no-op kept for
|
|
240
|
+
context-manager parity with a real DB-API connection."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
243
|
+
self._client = DatabricksClient(*args, **kwargs)
|
|
244
|
+
|
|
245
|
+
def cursor(self) -> Cursor:
|
|
246
|
+
return Cursor(self._client)
|
|
247
|
+
|
|
248
|
+
async def close(self) -> None:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
async def __aenter__(self) -> Connection:
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
255
|
+
await self.close()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def connect(*args: Any, **kwargs: Any) -> Connection:
|
|
259
|
+
"""Same signature as DatabricksClient -- `connect(host, warehouse_id,
|
|
260
|
+
token=..., ...)`."""
|
|
261
|
+
return Connection(*args, **kwargs)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arrowbricks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a DB-API-ish Cursor (fetchone/fetchmany/fetchall/fetchall_arrow) or NDJSON streaming. Single Arrow engine (arro3), no DuckDB.
|
|
5
|
+
Project-URL: Repository, https://github.com/bmsuisse/arrowbricks
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: arro3-core>=0.8
|
|
10
|
+
Requires-Dist: arro3-io>=0.8
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# arrowbricks
|
|
15
|
+
|
|
16
|
+
Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a `Cursor` shaped like [`databricks-sql-python`](https://github.com/databricks/databricks-sql-python)'s (`execute`, `fetchone`/`fetchmany`/`fetchall`, `fetchall_arrow`/`fetchmany_arrow`), or `stream_query_json` for streaming NDJSON. One Arrow engine ([arro3](https://github.com/kylebarron/arro3)), no DuckDB, no pandas/pyarrow.
|
|
17
|
+
|
|
18
|
+
- Single responsibility: Databricks to Arrow via arro3. No embedded query engine -- that's [duckbricks](https://github.com/bmsuisse/duckbricks), built on top of this.
|
|
19
|
+
- Bring-your-own-auth -- a static token or your own token-refresh callable. No cloud-SDK dependency baked in.
|
|
20
|
+
- Result-order preserved even though chunks can complete out of order over the network.
|
|
21
|
+
- Chunks are fetched lazily as `fetchone`/`fetchmany`/`fetchall` actually need them, not all upfront.
|
|
22
|
+
- Heartbeats between slow chunks (`execute_streamed`/`stream_query_json`), so a caller streaming this over e.g. SSE never goes silent during a cold warehouse start.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install arrowbricks
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Dependencies: `httpx` + `arro3-core` + `arro3-io`. That's the whole tree.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
from arrowbricks import connect
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
conn = connect(
|
|
41
|
+
host="adb-1234567890.1.azuredatabricks.net",
|
|
42
|
+
warehouse_id="abcd1234efgh5678",
|
|
43
|
+
token="dapi...", # or token_provider=... -- see Auth below
|
|
44
|
+
)
|
|
45
|
+
cursor = conn.cursor()
|
|
46
|
+
|
|
47
|
+
await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
|
|
48
|
+
async for row in cursor:
|
|
49
|
+
print(row)
|
|
50
|
+
|
|
51
|
+
await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
|
|
52
|
+
table = await cursor.fetchall_arrow() # an arro3 Table
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
For streaming NDJSON (e.g. a FastAPI SSE endpoint, first row out as soon as its chunk arrives):
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from arrowbricks import HEARTBEAT, DatabricksClient, stream_query_json
|
|
62
|
+
|
|
63
|
+
client = DatabricksClient(host=..., warehouse_id=..., token=...)
|
|
64
|
+
|
|
65
|
+
async for item in stream_query_json(client, "SELECT * FROM my_catalog.my_schema.big_table"):
|
|
66
|
+
if item is HEARTBEAT:
|
|
67
|
+
continue # forward as an SSE keep-alive comment, e.g.
|
|
68
|
+
print(item) # one ready-to-send JSON string per row
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Why not `databricks-sql-connector`?
|
|
72
|
+
|
|
73
|
+
The [official driver](https://github.com/databricks/databricks-sql-python) is the right choice if you need full DB-API 2.0 compatibility over Databricks' Thrift/ODBC-style protocol. If you just want a query result as Arrow/JSON in your own async app, it drags in a lot for that: `pandas`, `thrift`, `openpyxl`, `pybreaker`, `pyjwt`, `oauthlib`, `lz4`, `requests`, `urllib3` as hard dependencies. arrowbricks talks to the plain REST Statement Execution API instead, and its whole dependency tree is `httpx` + `arro3-core` + `arro3-io`. The `Cursor` API is deliberately shaped like the official driver's so switching between them is mostly a constructor change, but arrowbricks is async throughout (`execute`, `fetchone`, etc. are all coroutines) -- there's no sync escape hatch.
|
|
74
|
+
|
|
75
|
+
## Why not `duckbricks`?
|
|
76
|
+
|
|
77
|
+
[duckbricks](https://github.com/bmsuisse/duckbricks) does the same Databricks-to-Arrow work, then goes further: it uses a real embedded DuckDB engine to materialize results into your own DuckDB connection/table (`feed_select_to_duckdb_table`), or push a DuckDB query's result *up* to Databricks (`feed_duckdb_table_to_databricks`). If you need that -- a real local SQL engine sitting on top, not just "run this query, get Arrow/JSON back" -- use duckbricks; it depends on arrowbricks for the Databricks/Arrow half. If you don't need DuckDB at all, arrowbricks alone is the smaller, single-responsibility half.
|
|
78
|
+
|
|
79
|
+
## Auth
|
|
80
|
+
|
|
81
|
+
`connect`/`DatabricksClient` take either:
|
|
82
|
+
|
|
83
|
+
- `token: str` -- a static personal access token or pre-issued OAuth token, or
|
|
84
|
+
- `token_provider` -- a callable (sync or async) returning a token string, called on every request.
|
|
85
|
+
|
|
86
|
+
arrowbricks has no opinion on *how* you get a token and no cloud-SDK dependency of its own. If your provider is expensive to call, cache/refresh inside it -- arrowbricks does no caching on your behalf.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
conn = connect(host=..., warehouse_id=..., token_provider=my_token_provider)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
- `connect(host, warehouse_id, *, token=None, token_provider=None, ...) -> Connection`
|
|
95
|
+
- `Connection.cursor() -> Cursor`
|
|
96
|
+
- `Cursor.execute(sql, parameters=None, *, row_limit=None, offset=None, catalog=None, schema=None, total_timeout_s=None) -> Cursor` -- submits and waits for the statement, like a real DB-API cursor. `parameters`, if given, is Databricks' own named-parameter format -- `[{"name": ..., "value": ..., "type": ...}]` bound against `:name` markers in `sql`.
|
|
97
|
+
- `Cursor.execute_streamed(...)` -- same args, but an async generator yielding `HEARTBEAT` while waiting on a slow cold start, then the ready `Cursor` -- for bridging e.g. an SSE connection.
|
|
98
|
+
- `Cursor.fetchone() -> tuple | None`, `Cursor.fetchmany(size) -> list[tuple]`, `Cursor.fetchall() -> list[tuple]`
|
|
99
|
+
- `Cursor.fetchmany_arrow(size) -> arro3.core.Table`, `Cursor.fetchall_arrow() -> arro3.core.Table`
|
|
100
|
+
- `Cursor` is an async iterator, yielding one row (tuple) at a time.
|
|
101
|
+
- `Cursor.description` -- DB-API-style `[(name, type_name, None, None, None, None, None), ...]` after `execute()`.
|
|
102
|
+
- `stream_query_json(client, sql, **kwargs)` -- yields `HEARTBEAT`, then each row as a JSON string, as soon as its chunk arrives. Timestamps come out as full ISO-8601, every column key is always present (`"col":null` for a null value, never an omitted key).
|
|
103
|
+
- `DatabricksClient(host, warehouse_id, *, token=None, token_provider=None, ...)` -- the lower-level client `Connection` wraps. `client.execute_json_statement(sql, ...)` for plain JSON rows with no Arrow parse at all; `client.upload_volume_file(volume_path, data)`/`client.delete_volume_file(volume_path)` for the Files API.
|
|
104
|
+
- `write_ipc_stream(table_or_chunk, buf)` -- thin wrapper around `arro3.io.write_ipc_stream` that always writes uncompressed bodies (see below).
|
|
105
|
+
|
|
106
|
+
`Cursor.execute`/`execute_streamed`/`stream_query_json` all accept `catalog`, `schema`, `row_limit`, `offset`, and `total_timeout_s`.
|
|
107
|
+
|
|
108
|
+
## A note on Arrow IPC compression
|
|
109
|
+
|
|
110
|
+
`write_ipc_stream` (and everything in this package that serializes Arrow-IPC bytes) always writes **uncompressed** bodies. arro3's own default (`compression="LZ4"`) is transparently decompressed by DuckDB's Arrow reader, but not necessarily by every other Arrow IPC reader -- notably, `duckdb-wasm`'s browser-side decoder silently fails to parse LZ4-compressed bodies. If you're producing bytes that might be consumed by something other than a Python DuckDB connection, this default matters.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
arrowbricks/__init__.py,sha256=ztesGsBdZnd88gwezxhpay2ZpfiJIZt85BCenkySFEE,471
|
|
2
|
+
arrowbricks/_streaming.py,sha256=FkOYLqLUANvYTMiNISUUkQ_qcnXlAGErd1J_ogsookM,11217
|
|
3
|
+
arrowbricks/client.py,sha256=QZWEpZ_qGazFyq1nbCs5MDMnKmoCkudyQGv7nYNyqas,16950
|
|
4
|
+
arrowbricks/cursor.py,sha256=ql5rpkufyHY81s0UpduLLf8S6bjogzVDxNRQ7kcrwpU,10306
|
|
5
|
+
arrowbricks-0.1.0.dist-info/METADATA,sha256=O90txWepq1W__nkNN28qo4VkzrMOSQQ0F24a3573J2s,7340
|
|
6
|
+
arrowbricks-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
arrowbricks-0.1.0.dist-info/licenses/LICENSE,sha256=OGvcpI5L_-dM9pJ30WhYStEa2cIhvRo5GZ2i5_YPl9k,1070
|
|
8
|
+
arrowbricks-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BMS Suisse AG
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|