arrowbricks 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/AGENTS.md +4 -3
  2. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/PKG-INFO +8 -4
  3. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/README.md +7 -3
  4. arrowbricks-0.2.0/examples/fastapi_sse_pivot.py +78 -0
  5. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/pyproject.toml +1 -1
  6. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/src/arrowbricks/_streaming.py +26 -8
  7. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/src/arrowbricks/cursor.py +61 -11
  8. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/tests/test_cursor.py +57 -1
  9. arrowbricks-0.2.0/tests/test_result_set.py +74 -0
  10. arrowbricks-0.2.0/tests/test_streaming.py +82 -0
  11. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/uv.lock +1 -1
  12. arrowbricks-0.1.2/tests/test_streaming.py +0 -39
  13. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/.github/workflows/ci.yml +0 -0
  14. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/.github/workflows/release.yml +0 -0
  15. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/.gitignore +0 -0
  16. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/LICENSE +0 -0
  17. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/examples/azure_auth.py +0 -0
  18. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/examples/basic.py +0 -0
  19. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/examples/cursor_paging.py +0 -0
  20. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/examples/fastapi_sse.py +0 -0
  21. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/prek.toml +0 -0
  22. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/src/arrowbricks/__init__.py +0 -0
  23. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/src/arrowbricks/client.py +0 -0
  24. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/tests/conftest.py +0 -0
  25. {arrowbricks-0.1.2 → arrowbricks-0.2.0}/tests/test_client.py +0 -0
@@ -6,9 +6,9 @@ Runs SQL against a Databricks SQL warehouse via the Statement Execution API and
6
6
 
7
7
  - `src/arrowbricks/client.py` -- pure REST client (auth, statement submission/polling, backpressure-bounded concurrent chunk download). No Arrow dependency at all, intentionally: someone who only wants `execute_json_statement` or `upload_volume_file`/`delete_volume_file` shouldn't need arro3 pulled in conceptually either, even though it's a hard dependency of the package as a whole. Retries are a small hand-rolled `_retry_call` loop, not a dependency (see "Design invariants" below).
8
8
  - `src/arrowbricks/_streaming.py` -- Arrow (de)serialization via arro3, always: `ReplayableArrowChunk`, `write_ipc_stream` (always uncompressed, see below), heartbeat helpers, chunk fetching, and `stream_query_json` (arro3's `write_ndjson`). One Arrow engine, no pluggable backend -- unlike duckbricks, there's nothing here to make pluggable; arro3 *is* the whole point.
9
- - `src/arrowbricks/cursor.py` -- `Connection`/`Cursor`, the DB-API-ish surface (`execute`/`execute_streamed`, `fetchone`/`fetchmany`/`fetchall`, `fetchall_arrow`/`fetchmany_arrow`). `_ResultSet` buffers at the Arrow-Table level (not materialized Python rows) so the Arrow-native fetch methods stay zero-copy; row-based fetches materialize lazily off that buffer.
9
+ - `src/arrowbricks/cursor.py` -- `Connection`/`Cursor`, the DB-API-ish surface (`execute`/`execute_streamed`, `fetchone`/`fetchmany`/`fetchall`, `fetchall_arrow`/`fetchmany_arrow`, `fetchall_streamed`/`fetchall_arrow_streamed`). `_ResultSet` buffers at the Arrow-Table level (not materialized Python rows) so the Arrow-native fetch methods stay zero-copy; row-based fetches materialize lazily off that buffer.
10
10
  - `tests/` -- respx mocks the Databricks REST endpoints (warehouse status, statement submit, chunk-link resolution, external-link byte download); no real warehouse or credentials needed to run the suite.
11
- - `examples/` -- `basic.py` (static token), `cursor_paging.py` (fetchmany/fetchmany_arrow over a large result), `fastapi_sse.py` (streaming NDJSON as SSE), `azure_auth.py` (Azure AD `token_provider` via `azure-identity`, kept out of core deps on purpose -- don't widen `ty check`'s scope to include it).
11
+ - `examples/` -- `basic.py` (static token), `cursor_paging.py` (fetchmany/fetchmany_arrow over a large result), `fastapi_sse.py` (streaming NDJSON as SSE), `fastapi_sse_pivot.py` (buffered fetchall_streamed with one combined heartbeat/timeout budget across the wait+download phases), `azure_auth.py` (Azure AD `token_provider` via `azure-identity`, kept out of core deps on purpose -- don't widen `ty check`'s scope to include it).
12
12
 
13
13
  ## Commands
14
14
 
@@ -25,8 +25,9 @@ One-time setup per clone: `prek install` (needs `uv tool install prek` first if
25
25
 
26
26
  - **No cloud-SDK dependency.** Auth is `token: str` or `token_provider: Callable[[], str | Awaitable[str]]`. Do not add `azure-identity`/`boto3`/etc. as a real dependency -- that belongs in the caller's app.
27
27
  - **No hardcoded catalog/schema.** `catalog`/`schema` default to `None` everywhere. This package has zero knowledge of any specific Databricks workspace's naming.
28
- - **Chunk order is not fetch order.** `DatabricksClient` fetches chunks concurrently (bounded, with backpressure) and they can complete out of order. `_ResultSet`/`stream_query_json` both hold a `pending: dict[int, chunk]` reorder buffer keyed by `chunk_index`, releasing in order as the next expected index shows up. If you touch either, keep a test proving order survives out-of-order arrival (see `test_fetchall_preserves_order_despite_out_of_order_chunks`, `test_stream_query_json_preserves_order_despite_out_of_order_chunks`).
28
+ - **Chunk order is not fetch order, and a chunk_index is not guaranteed unique or contiguous.** `DatabricksClient` fetches chunks concurrently (bounded, with backpressure) and they can complete out of order. `_ResultSet`/`stream_query_json` both hold a `pending: dict[int, list[chunk]]` reorder buffer keyed by `chunk_index` -- a **list**, not a single chunk, because `_fetch_chunk_index` can yield more than one blob for the same index (multiple `external_links` per chunk, "usually exactly one" but not guaranteed). Releases in order as the next expected index empties out; once the source is exhausted, a genuine gap (an index that never showed up at all) drains the lowest remaining index instead of stranding everything buffered past it. If you touch either, keep a test proving order survives out-of-order arrival AND that duplicate/missing indices never lose rows (see `test_fetchall_preserves_order_despite_out_of_order_chunks`, `test_stream_query_json_preserves_order_despite_out_of_order_chunks`, `tests/test_result_set.py`).
29
29
  - **Chunks are fetched lazily, not all upfront.** `_ResultSet` only pulls the next chunk from `_chunk_aiter` when the caller's `fetchone`/`fetchmany`/`fetchall` actually needs more rows than are already buffered. Don't "simplify" this into draining the whole chunk iterator inside `execute()` -- that defeats the point of a paginated cursor.
30
+ - **`execute_streamed`'s heartbeat/timeout only covers the wait for the statement to become ready -- not downloading any chunk.** That was a real bug in this package's first release: a caller doing `execute_streamed()` then `fetchall()` got zero timeout enforcement and zero heartbeats during a slow multi-chunk download, exactly the case heartbeats exist for. `fetchall_streamed`/`fetchall_arrow_streamed` cover that second phase -- a caller wanting one combined budget across both phases must track its own deadline and pass the *remaining* time into the second call (see `examples/fastapi_sse_pivot.py`), since two independently-clocked `total_timeout_s`s would let a pathological case run up to 2x the intended ceiling.
30
31
  - **No silent row caps.** There's no `ABSOLUTE_ROW_LIMIT`-style ceiling baked in. If a caller wants one, that's `row_limit`, which they pass explicitly.
31
32
  - **No retry dependency.** `client.py`'s `_retry_call` is a ~10-line hand-rolled exponential-backoff loop, replacing tenacity on purpose -- it's the only retry pattern in the whole client, so a dependency for it wasn't worth it.
32
33
  - **One Arrow engine, no pluggable backend.** Unlike duckbricks (which supports nanoarrow *or* arro3 *or* bring-your-own), arrowbricks is arro3-only by design -- that's the entire "single responsibility" pitch. Don't add a backend-abstraction layer back in; if a caller needs a different Arrow engine, that's duckbricks' `set_arrow_backend()`, not this package.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrowbricks
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
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
5
  Project-URL: Repository, https://github.com/bmsuisse/arrowbricks
6
6
  License-Expression: MIT
@@ -72,8 +72,11 @@ See [`examples/basic.py`](examples/basic.py) for a runnable version,
72
72
  [`examples/cursor_paging.py`](examples/cursor_paging.py) for paging a large
73
73
  result with `fetchmany`/`fetchmany_arrow` without buffering it all upfront,
74
74
  [`examples/fastapi_sse.py`](examples/fastapi_sse.py) for streaming a query to
75
- a client as Server-Sent Events, or [`examples/azure_auth.py`](examples/azure_auth.py)
76
- for a caching `token_provider` built on Azure AD (`DefaultAzureCredential`).
75
+ a client as Server-Sent Events, [`examples/fastapi_sse_pivot.py`](examples/fastapi_sse_pivot.py)
76
+ for the same over a buffered `Cursor.fetchall_streamed` result with one
77
+ combined heartbeat/timeout budget across both the wait and the download, or
78
+ [`examples/azure_auth.py`](examples/azure_auth.py) for a caching
79
+ `token_provider` built on Azure AD (`DefaultAzureCredential`).
77
80
 
78
81
  ## Why not `databricks-sql-connector`?
79
82
 
@@ -102,9 +105,10 @@ conn = connect(host=..., warehouse_id=..., token_provider=my_token_provider)
102
105
  - `Connection.cursor() -> Cursor`
103
106
  - `Connection.client -> DatabricksClient` -- the same client `cursor()` uses, for lower-level access (e.g. `stream_query_json`, `execute_json_statement`, `upload_volume_file`).
104
107
  - `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`.
105
- - `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.
108
+ - `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. Its timeout/heartbeats stop the moment the statement is ready, *before* any chunk has been downloaded -- see `fetchall_streamed` below for the download phase itself.
106
109
  - `Cursor.fetchone() -> tuple | None`, `Cursor.fetchmany(size) -> list[tuple]`, `Cursor.fetchall() -> list[tuple]`
107
110
  - `Cursor.fetchmany_arrow(size) -> arro3.core.Table`, `Cursor.fetchall_arrow() -> arro3.core.Table`
111
+ - `Cursor.fetchall_streamed(*, total_timeout_s=None)` / `Cursor.fetchall_arrow_streamed(*, total_timeout_s=None)` -- like `fetchall()`/`fetchall_arrow()`, but yield `HEARTBEAT` while pulling chunks instead of blocking silently, then the final rows/Table -- for a caller downloading a large result over SSE who needs heartbeats (and a timeout) through the *download*, not just the initial wait. Compose with `execute_streamed` and a shared deadline if you want one combined budget across both phases (see `examples/fastapi_sse_pivot.py`).
108
112
  - `Cursor` is an async iterator, yielding one row (tuple) at a time.
109
113
  - `Cursor.description` -- DB-API-style `[(name, type_name, None, None, None, None, None), ...]` after `execute()`.
110
114
  - `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).
@@ -59,8 +59,11 @@ See [`examples/basic.py`](examples/basic.py) for a runnable version,
59
59
  [`examples/cursor_paging.py`](examples/cursor_paging.py) for paging a large
60
60
  result with `fetchmany`/`fetchmany_arrow` without buffering it all upfront,
61
61
  [`examples/fastapi_sse.py`](examples/fastapi_sse.py) for streaming a query to
62
- a client as Server-Sent Events, or [`examples/azure_auth.py`](examples/azure_auth.py)
63
- for a caching `token_provider` built on Azure AD (`DefaultAzureCredential`).
62
+ a client as Server-Sent Events, [`examples/fastapi_sse_pivot.py`](examples/fastapi_sse_pivot.py)
63
+ for the same over a buffered `Cursor.fetchall_streamed` result with one
64
+ combined heartbeat/timeout budget across both the wait and the download, or
65
+ [`examples/azure_auth.py`](examples/azure_auth.py) for a caching
66
+ `token_provider` built on Azure AD (`DefaultAzureCredential`).
64
67
 
65
68
  ## Why not `databricks-sql-connector`?
66
69
 
@@ -89,9 +92,10 @@ conn = connect(host=..., warehouse_id=..., token_provider=my_token_provider)
89
92
  - `Connection.cursor() -> Cursor`
90
93
  - `Connection.client -> DatabricksClient` -- the same client `cursor()` uses, for lower-level access (e.g. `stream_query_json`, `execute_json_statement`, `upload_volume_file`).
91
94
  - `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`.
92
- - `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.
95
+ - `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. Its timeout/heartbeats stop the moment the statement is ready, *before* any chunk has been downloaded -- see `fetchall_streamed` below for the download phase itself.
93
96
  - `Cursor.fetchone() -> tuple | None`, `Cursor.fetchmany(size) -> list[tuple]`, `Cursor.fetchall() -> list[tuple]`
94
97
  - `Cursor.fetchmany_arrow(size) -> arro3.core.Table`, `Cursor.fetchall_arrow() -> arro3.core.Table`
98
+ - `Cursor.fetchall_streamed(*, total_timeout_s=None)` / `Cursor.fetchall_arrow_streamed(*, total_timeout_s=None)` -- like `fetchall()`/`fetchall_arrow()`, but yield `HEARTBEAT` while pulling chunks instead of blocking silently, then the final rows/Table -- for a caller downloading a large result over SSE who needs heartbeats (and a timeout) through the *download*, not just the initial wait. Compose with `execute_streamed` and a shared deadline if you want one combined budget across both phases (see `examples/fastapi_sse_pivot.py`).
95
99
  - `Cursor` is an async iterator, yielding one row (tuple) at a time.
96
100
  - `Cursor.description` -- DB-API-style `[(name, type_name, None, None, None, None, None), ...]` after `execute()`.
97
101
  - `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).
@@ -0,0 +1,78 @@
1
+ """Stream a large result to a client over SSE with ONE combined heartbeat/
2
+ timeout budget spanning both phases: waiting for the statement to complete
3
+ (execute_streamed) AND downloading every chunk afterwards (fetchall_streamed)
4
+ -- the two-phase composition `fastapi_sse.py`'s simpler stream_query_json
5
+ example doesn't need, since that one streams rows as chunks arrive rather
6
+ than buffering a full result first.
7
+
8
+ Splitting the timeout into two separately-clocked halves (each given the
9
+ same total_timeout_s) would let a pathological case run up to 2x the
10
+ intended ceiling -- tracking one deadline and passing the *remaining* budget
11
+ into the second phase keeps it as one real ceiling across both.
12
+
13
+ Requires `pip install arrowbricks fastapi uvicorn`.
14
+
15
+ DATABRICKS_HOST=adb-1234567890.1.azuredatabricks.net \\
16
+ DATABRICKS_WAREHOUSE_ID=abcd1234efgh5678 \\
17
+ DATABRICKS_TOKEN=dapi... \\
18
+ uvicorn examples.fastapi_sse_pivot:app --reload
19
+
20
+ Then, in another terminal:
21
+
22
+ curl -N "http://localhost:8000/pivot?sql=SELECT+*+FROM+range(1000000)"
23
+
24
+ Same caveat as fastapi_sse.py: `sql` comes straight from the request for
25
+ brevity -- validate/allowlist it in a real deployment.
26
+ """
27
+
28
+ import asyncio
29
+ import os
30
+ from collections.abc import AsyncIterator
31
+
32
+ from fastapi import FastAPI
33
+ from fastapi.responses import StreamingResponse
34
+
35
+ from arrowbricks import HEARTBEAT, connect
36
+ from arrowbricks.cursor import Cursor
37
+
38
+ app = FastAPI()
39
+
40
+ conn = connect(
41
+ host=os.environ["DATABRICKS_HOST"],
42
+ warehouse_id=os.environ["DATABRICKS_WAREHOUSE_ID"],
43
+ token=os.environ["DATABRICKS_TOKEN"],
44
+ )
45
+
46
+ _TOTAL_TIMEOUT_S = 300.0
47
+
48
+
49
+ async def _fetch_all_rows_with_heartbeat(cursor: Cursor, deadline: float) -> AsyncIterator[object]:
50
+ """Yields HEARTBEAT while downloading, then the final list[Row] -- bounded
51
+ by whatever's left of the shared deadline, not a fresh full timeout."""
52
+ loop = asyncio.get_running_loop()
53
+ remaining = max(deadline - loop.time(), 0.0)
54
+ async for item in cursor.fetchall_streamed(total_timeout_s=remaining):
55
+ yield item
56
+
57
+
58
+ async def _sse(sql: str) -> AsyncIterator[str]:
59
+ loop = asyncio.get_running_loop()
60
+ deadline = loop.time() + _TOTAL_TIMEOUT_S
61
+ cursor = conn.cursor()
62
+
63
+ async for item in cursor.execute_streamed(sql, total_timeout_s=_TOTAL_TIMEOUT_S):
64
+ if item is HEARTBEAT:
65
+ yield ": keep-alive\n\n"
66
+ continue
67
+ async for fetch_item in _fetch_all_rows_with_heartbeat(cursor, deadline):
68
+ if fetch_item is HEARTBEAT:
69
+ yield ": keep-alive\n\n"
70
+ else:
71
+ for row in fetch_item:
72
+ yield f"data: {row}\n\n"
73
+ yield "event: end\ndata: {}\n\n"
74
+
75
+
76
+ @app.get("/pivot")
77
+ async def pivot(sql: str) -> StreamingResponse:
78
+ return StreamingResponse(_sse(sql), media_type="text/event-stream")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arrowbricks"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "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
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -236,7 +236,11 @@ async def stream_query_json(
236
236
  O(whole result). Chunks can arrive out of order, so out-of-order arrivals
237
237
  sit in a small `pending` buffer until the next expected chunk_index shows
238
238
  up -- that buffer stays bounded by concurrency, it never grows to the full
239
- result.
239
+ result. `pending` holds a *list* per index, not a single chunk, since a
240
+ chunk_index can have more than one blob (DatabricksClient._fetch_chunk_index
241
+ gathers over possibly-multiple `external_links` per chunk); once the
242
+ source is exhausted, any index that never showed up at all is skipped
243
+ rather than stranding everything buffered past it forever.
240
244
 
241
245
  Note this yields a whole chunk's rows at once (write_ndjson has no
242
246
  incremental/row-at-a-time mode) -- Databricks' own chunk sizing already
@@ -247,17 +251,31 @@ async def stream_query_json(
247
251
  client, sql, catalog=catalog, schema=schema, parameters=params
248
252
  )
249
253
 
250
- pending: dict[int, ReplayableArrowChunk] = {}
254
+ pending: dict[int, list[ReplayableArrowChunk]] = {}
251
255
  next_idx = 0
252
256
  loop = asyncio.get_running_loop()
257
+
258
+ async def _emit_lines(chunk: ReplayableArrowChunk) -> AsyncIterator[str]:
259
+ blob = await loop.run_in_executor(None, _write_ndjson, chunk)
260
+ for line in blob.splitlines():
261
+ yield line.decode()
262
+
253
263
  async for item in heartbeat_over_stream(chunk_iter, total_timeout_s=total_timeout_s):
254
264
  if item is HEARTBEAT:
255
265
  yield HEARTBEAT
256
266
  continue
257
- pending[item.chunk_index] = item
267
+ pending.setdefault(item.chunk_index, []).append(item)
258
268
  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
269
+ chunk = pending[next_idx].pop(0)
270
+ if not pending[next_idx]:
271
+ del pending[next_idx]
272
+ next_idx += 1
273
+ async for line in _emit_lines(chunk):
274
+ yield line
275
+
276
+ # A genuine gap (an index that never arrived) must not strand chunks
277
+ # buffered past it -- drain whatever's left, in ascending index order.
278
+ for idx in sorted(pending):
279
+ for chunk in pending[idx]:
280
+ async for line in _emit_lines(chunk):
281
+ yield line
@@ -54,32 +54,57 @@ class _ResultSet:
54
54
  def __init__(self, schema: core.Schema | None, chunk_aiter: AsyncIterator[ReplayableArrowChunk]) -> None:
55
55
  self.schema = schema
56
56
  self._chunk_aiter = chunk_aiter
57
- self._pending: dict[int, ReplayableArrowChunk] = {}
57
+ # A list per index, not a single chunk -- a chunk_index can have more
58
+ # than one blob (DatabricksClient._fetch_chunk_index gathers over
59
+ # possibly-multiple `external_links` per chunk, "usually exactly one"
60
+ # but not guaranteed to be), and a dict keyed by index alone would
61
+ # silently overwrite/lose the first blob when a second one for the
62
+ # same index arrives.
63
+ self._pending: dict[int, list[ReplayableArrowChunk]] = {}
58
64
  self._next_idx = 0
59
65
  self._exhausted = False
60
66
  self._buffer: core.Table | None = None
61
67
  self.rownumber = 0
62
68
 
69
+ def _pop_pending(self, idx: int) -> ReplayableArrowChunk:
70
+ blobs = self._pending[idx]
71
+ chunk = blobs.pop(0)
72
+ if not blobs:
73
+ del self._pending[idx]
74
+ return chunk
75
+
63
76
  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."""
77
+ """Returns one chunk's Table, preferring the next expected index --
78
+ checking `_pending` FIRST, since a single earlier call can have pulled
79
+ several chunks off `_chunk_aiter` before the one it actually needed
80
+ showed up (arrival order is completion order, not chunk_index order),
81
+ leaving the rest already-fetched-and-buffered here. Only touches the
82
+ network (`_chunk_aiter.__anext__()`) once `_pending` has nothing more
83
+ to give for the current index.
84
+
85
+ Once the source is exhausted, `_next_idx` reaching that exact value is
86
+ no longer required: if some index was skipped entirely (e.g. a chunk
87
+ whose bytes came back empty, see fetch_arrow_chunks_for_statement),
88
+ waiting for it forever would silently strand every higher-indexed
89
+ chunk already sitting in `_pending`. Draining the lowest remaining
90
+ index instead means a genuine gap costs row *order* past that point,
91
+ never lost rows."""
70
92
  while True:
71
93
  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()
94
+ chunk = self._pop_pending(self._next_idx)
95
+ if self._next_idx not in self._pending:
96
+ self._next_idx += 1
97
+ return chunk.to_table()
75
98
  if self._exhausted:
99
+ if self._pending:
100
+ return self._pop_pending(min(self._pending)).to_table()
76
101
  return None
77
102
  try:
78
103
  chunk = await self._chunk_aiter.__anext__()
79
104
  except StopAsyncIteration:
80
105
  self._exhausted = True
81
106
  continue
82
- self._pending[chunk.chunk_index] = chunk
107
+ self._pending.setdefault(chunk.chunk_index, []).append(chunk)
83
108
 
84
109
  async def _ensure_buffer(self, want: int) -> None:
85
110
  while (self._buffer is None or self._buffer.num_rows < want) and not self._exhausted:
@@ -233,6 +258,31 @@ class Cursor:
233
258
  async def fetchall_arrow(self) -> core.Table:
234
259
  return await self._require_result().fetchall_arrow()
235
260
 
261
+ def fetchall_streamed(self, *, total_timeout_s: float | None = None) -> AsyncIterator[Any]:
262
+ """Like fetchall(), but yields HEARTBEAT while pulling chunks instead
263
+ of blocking silently -- for a caller bridging e.g. an SSE connection
264
+ through the full download, not just the initial `execute_streamed`
265
+ wait for the statement to complete. Downloading many chunks for a
266
+ large result can itself take a while; `execute_streamed`'s own
267
+ heartbeats stop the moment the statement is ready, before any chunk
268
+ has actually been fetched. Yields HEARTBEAT zero or more times, then
269
+ the final `list[Row]`."""
270
+
271
+ async def _gen() -> AsyncIterator[Any]:
272
+ async for item in await_with_heartbeat(self.fetchall(), total_timeout_s=total_timeout_s):
273
+ yield item
274
+
275
+ return _gen()
276
+
277
+ def fetchall_arrow_streamed(self, *, total_timeout_s: float | None = None) -> AsyncIterator[Any]:
278
+ """Arrow-`Table` counterpart to fetchall_streamed -- see its docstring."""
279
+
280
+ async def _gen() -> AsyncIterator[Any]:
281
+ async for item in await_with_heartbeat(self.fetchall_arrow(), total_timeout_s=total_timeout_s):
282
+ yield item
283
+
284
+ return _gen()
285
+
236
286
  def __aiter__(self) -> Cursor:
237
287
  return self
238
288
 
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+
3
5
  import httpx
4
6
  import pytest
5
7
  import respx
6
8
 
7
- from arrowbricks import HEARTBEAT, DatabricksClient
9
+ from arrowbricks import HEARTBEAT, DatabricksClient, QueryTimeout
8
10
  from arrowbricks.cursor import Cursor
9
11
 
10
12
 
@@ -153,6 +155,60 @@ async def test_execute_streamed_emits_heartbeat_before_cursor_ready(mock_warehou
153
155
  assert len(await cursor.fetchall()) == 3
154
156
 
155
157
 
158
+ @pytest.mark.asyncio
159
+ @respx.mock
160
+ async def test_fetchall_streamed_times_out_on_a_slow_chunk_download(warehouse_host_id):
161
+ """execute_streamed's own timeout only covers the wait for the statement
162
+ to become ready -- it stops the moment chunks are available to fetch,
163
+ before any chunk has actually been downloaded. A slow chunk download must
164
+ still be bounded, which is exactly what fetchall_streamed adds."""
165
+ host, warehouse_id = warehouse_host_id
166
+ respx.mock.get(f"{host}/api/2.0/sql/warehouses/{warehouse_id}").mock(
167
+ return_value=httpx.Response(200, json={"state": "RUNNING"})
168
+ )
169
+ respx.mock.post(f"{host}/api/2.0/sql/statements").mock(
170
+ return_value=httpx.Response(
171
+ 200,
172
+ json={
173
+ "statement_id": "stmt-slow",
174
+ "status": {"state": "SUCCEEDED"},
175
+ "manifest": {"chunks": [{"chunk_index": 0, "row_count": 3}]},
176
+ },
177
+ )
178
+ )
179
+ respx.mock.get(f"{host}/api/2.0/sql/statements/stmt-slow/result/chunks/0").mock(
180
+ return_value=httpx.Response(200, json={"external_links": [{"external_link": f"{host}/_data/slow-chunk"}]})
181
+ )
182
+
183
+ async def _slow_chunk(request: httpx.Request) -> httpx.Response:
184
+ await asyncio.sleep(10)
185
+ raise AssertionError("unreachable -- test should time out first")
186
+
187
+ respx.mock.get(f"{host}/_data/slow-chunk").mock(side_effect=_slow_chunk)
188
+ client = DatabricksClient(host, warehouse_id, token="test-token")
189
+ cursor = Cursor(client)
190
+ await cursor.execute("SELECT * FROM whatever")
191
+
192
+ with pytest.raises(QueryTimeout):
193
+ async for _ in cursor.fetchall_streamed(total_timeout_s=0.05):
194
+ pass
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ @respx.mock
199
+ async def test_fetchall_streamed_yields_result_after_zero_or_more_heartbeats(mock_warehouse, warehouse_host_id):
200
+ host, warehouse_id = warehouse_host_id
201
+ mock_warehouse(respx.mock, n_chunks=1, rows_per_chunk=3)
202
+ client = DatabricksClient(host, warehouse_id, token="test-token")
203
+ cursor = Cursor(client)
204
+ await cursor.execute("SELECT * FROM whatever")
205
+
206
+ items = [item async for item in cursor.fetchall_streamed(total_timeout_s=5)]
207
+
208
+ assert all(item is HEARTBEAT for item in items[:-1])
209
+ assert [r[0] for r in items[-1]] == [0, 1, 2]
210
+
211
+
156
212
  @pytest.mark.asyncio
157
213
  async def test_fetch_before_execute_raises():
158
214
  client = DatabricksClient("https://fake", "wh", token="test-token")
@@ -0,0 +1,74 @@
1
+ """Regression tests for _ResultSet's chunk reorder buffer -- specifically the
2
+ two failure modes a naive dict[int, chunk] keyed-by-index buffer gets wrong:
3
+ a chunk_index that shows up more than once (DatabricksClient._fetch_chunk_index
4
+ gathers over possibly-multiple external_links per chunk), and a chunk_index
5
+ that never shows up at all (fetch_arrow_chunks_for_statement skips falsy
6
+ blobs). Both must never lose rows -- see cursor.py's _pull_one_chunk_table
7
+ docstring."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator
12
+
13
+ import pytest
14
+
15
+ from arrowbricks._streaming import ReplayableArrowChunk
16
+ from arrowbricks.cursor import _ResultSet
17
+
18
+
19
+ async def _aiter(chunks: list[ReplayableArrowChunk]) -> AsyncIterator[ReplayableArrowChunk]:
20
+ for c in chunks:
21
+ yield c
22
+
23
+
24
+ def _ids(chunk_bytes_builder, lo: int, hi: int, chunk_index: int) -> ReplayableArrowChunk:
25
+ return ReplayableArrowChunk(chunk_bytes_builder(lo, hi), chunk_index=chunk_index)
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_duplicate_chunk_index_keeps_both_blobs(chunk_bytes_builder):
30
+ """Two separate blobs arriving under the SAME chunk_index (multiple
31
+ external_links for one chunk) must both survive, not overwrite each
32
+ other."""
33
+ chunks = [
34
+ _ids(chunk_bytes_builder, 0, 2, chunk_index=0),
35
+ _ids(chunk_bytes_builder, 5, 7, chunk_index=0), # same index, second blob
36
+ _ids(chunk_bytes_builder, 2, 4, chunk_index=1),
37
+ ]
38
+ result = _ResultSet(schema=None, chunk_aiter=_aiter(chunks))
39
+
40
+ table = await result.fetchall_arrow()
41
+
42
+ assert sorted(table.column(0).combine_chunks().to_pylist()) == [0, 1, 2, 3, 5, 6]
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_gap_in_chunk_index_does_not_strand_later_chunks(chunk_bytes_builder):
47
+ """chunk_index 1 never arrives at all (e.g. its bytes came back empty and
48
+ got filtered upstream) -- chunk 2's rows must still come out, not get
49
+ stranded behind a hole that never fills in."""
50
+ chunks = [
51
+ _ids(chunk_bytes_builder, 0, 2, chunk_index=0),
52
+ _ids(chunk_bytes_builder, 2, 4, chunk_index=2), # index 1 is missing
53
+ ]
54
+ result = _ResultSet(schema=None, chunk_aiter=_aiter(chunks))
55
+
56
+ table = await result.fetchall_arrow()
57
+
58
+ assert sorted(table.column(0).combine_chunks().to_pylist()) == [0, 1, 2, 3]
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_out_of_order_arrival_still_preserves_row_order(chunk_bytes_builder):
63
+ """The common case (no gaps, no duplicates, just network completion
64
+ order != chunk_index order) must still come out in chunk_index order."""
65
+ chunks = [
66
+ _ids(chunk_bytes_builder, 4, 6, chunk_index=2),
67
+ _ids(chunk_bytes_builder, 0, 2, chunk_index=0),
68
+ _ids(chunk_bytes_builder, 2, 4, chunk_index=1),
69
+ ]
70
+ result = _ResultSet(schema=None, chunk_aiter=_aiter(chunks))
71
+
72
+ table = await result.fetchall_arrow()
73
+
74
+ assert table.column(0).combine_chunks().to_pylist() == [0, 1, 2, 3, 4, 5]
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from arrowbricks import DatabricksClient, stream_query_json
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ @respx.mock
14
+ async def test_stream_query_json_preserves_order_despite_out_of_order_chunks(mock_warehouse, warehouse_host_id):
15
+ host, warehouse_id = warehouse_host_id
16
+ # Chunks resolve in REVERSE completion order (see conftest's
17
+ # reverse_arrival) -- this is the scenario that silently breaks ORDER BY
18
+ # if a caller doesn't reorder before emitting.
19
+ mock_warehouse(respx.mock, n_chunks=4, rows_per_chunk=5, reverse_arrival=True)
20
+ client = DatabricksClient(host, warehouse_id, token="test-token")
21
+
22
+ rows = [json.loads(row) async for row in stream_query_json(client, "SELECT * FROM whatever ORDER BY id")]
23
+
24
+ assert [r["id"] for r in rows] == list(range(20))
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ @respx.mock
29
+ async def test_stream_query_json_row_shape(mock_warehouse, warehouse_host_id):
30
+ host, warehouse_id = warehouse_host_id
31
+ mock_warehouse(respx.mock, n_chunks=1, rows_per_chunk=3)
32
+ client = DatabricksClient(host, warehouse_id, token="test-token")
33
+
34
+ rows = [json.loads(row) async for row in stream_query_json(client, "SELECT * FROM whatever")]
35
+
36
+ assert rows == [
37
+ {"id": 0, "label": "row_0"},
38
+ {"id": 1, "label": "row_1"},
39
+ {"id": 2, "label": "row_2"},
40
+ ]
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ @respx.mock
45
+ async def test_stream_query_json_survives_duplicate_index_and_a_gap(warehouse_host_id, chunk_bytes_builder):
46
+ """chunk_index 0 has TWO external_links (a real, if uncommon, shape --
47
+ DatabricksClient._fetch_chunk_index gathers over all of them), and index 1
48
+ never shows up at all. Neither must lose rows -- see cursor.py's
49
+ _pull_one_chunk_table / this module's stream_query_json docstrings."""
50
+ host, warehouse_id = warehouse_host_id
51
+ statement_id = "stmt-dup-gap"
52
+ respx.mock.get(f"{host}/api/2.0/sql/warehouses/{warehouse_id}").mock(
53
+ return_value=httpx.Response(200, json={"state": "RUNNING"})
54
+ )
55
+ respx.mock.post(f"{host}/api/2.0/sql/statements").mock(
56
+ return_value=httpx.Response(
57
+ 200,
58
+ json={
59
+ "statement_id": statement_id,
60
+ "status": {"state": "SUCCEEDED"},
61
+ # index 1 deliberately absent -- a genuine gap.
62
+ "manifest": {"chunks": [{"chunk_index": 0, "row_count": 4}, {"chunk_index": 2, "row_count": 2}]},
63
+ },
64
+ )
65
+ )
66
+ respx.mock.get(f"{host}/api/2.0/sql/statements/{statement_id}/result/chunks/0").mock(
67
+ return_value=httpx.Response(
68
+ 200,
69
+ json={"external_links": [{"external_link": f"{host}/_data/0a"}, {"external_link": f"{host}/_data/0b"}]},
70
+ )
71
+ )
72
+ respx.mock.get(f"{host}/api/2.0/sql/statements/{statement_id}/result/chunks/2").mock(
73
+ return_value=httpx.Response(200, json={"external_links": [{"external_link": f"{host}/_data/2"}]})
74
+ )
75
+ respx.mock.get(f"{host}/_data/0a").mock(return_value=httpx.Response(200, content=chunk_bytes_builder(0, 2)))
76
+ respx.mock.get(f"{host}/_data/0b").mock(return_value=httpx.Response(200, content=chunk_bytes_builder(10, 12)))
77
+ respx.mock.get(f"{host}/_data/2").mock(return_value=httpx.Response(200, content=chunk_bytes_builder(20, 22)))
78
+ client = DatabricksClient(host, warehouse_id, token="test-token")
79
+
80
+ rows = [json.loads(row) async for row in stream_query_json(client, "SELECT * FROM whatever")]
81
+
82
+ assert sorted(r["id"] for r in rows) == [0, 1, 10, 11, 20, 21]
@@ -145,7 +145,7 @@ wheels = [
145
145
 
146
146
  [[package]]
147
147
  name = "arrowbricks"
148
- version = "0.1.2"
148
+ version = "0.2.0"
149
149
  source = { editable = "." }
150
150
  dependencies = [
151
151
  { name = "arro3-core" },
@@ -1,39 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
-
5
- import pytest
6
- import respx
7
-
8
- from arrowbricks import DatabricksClient, stream_query_json
9
-
10
-
11
- @pytest.mark.asyncio
12
- @respx.mock
13
- async def test_stream_query_json_preserves_order_despite_out_of_order_chunks(mock_warehouse, warehouse_host_id):
14
- host, warehouse_id = warehouse_host_id
15
- # Chunks resolve in REVERSE completion order (see conftest's
16
- # reverse_arrival) -- this is the scenario that silently breaks ORDER BY
17
- # if a caller doesn't reorder before emitting.
18
- mock_warehouse(respx.mock, n_chunks=4, rows_per_chunk=5, reverse_arrival=True)
19
- client = DatabricksClient(host, warehouse_id, token="test-token")
20
-
21
- rows = [json.loads(row) async for row in stream_query_json(client, "SELECT * FROM whatever ORDER BY id")]
22
-
23
- assert [r["id"] for r in rows] == list(range(20))
24
-
25
-
26
- @pytest.mark.asyncio
27
- @respx.mock
28
- async def test_stream_query_json_row_shape(mock_warehouse, warehouse_host_id):
29
- host, warehouse_id = warehouse_host_id
30
- mock_warehouse(respx.mock, n_chunks=1, rows_per_chunk=3)
31
- client = DatabricksClient(host, warehouse_id, token="test-token")
32
-
33
- rows = [json.loads(row) async for row in stream_query_json(client, "SELECT * FROM whatever")]
34
-
35
- assert rows == [
36
- {"id": 0, "label": "row_0"},
37
- {"id": 1, "label": "row_1"},
38
- {"id": 2, "label": "row_2"},
39
- ]
File without changes
File without changes
File without changes