Skip to content

Commit e01d7fc

Browse files
authored
test(integration): run arrow scenarios in CI instead of skipping (#102)
* test(integration): run arrow scenarios in CI instead of skipping * test(integration): align arrow scenarios with prod behavior * test(integration): drop purge_table_cache, unsupported on managed catalogs * fix(arrow): drain connection before release to avoid poisoning the pool
1 parent 5aee4b4 commit e01d7fc

5 files changed

Lines changed: 70 additions & 35 deletions

File tree

hotdata/arrow.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ def __init__(self, status: str, result_id: str) -> None:
5050
)
5151

5252

53+
def _release_conn(response: Any) -> None:
54+
"""Return a urllib3 connection to the pool without poisoning it.
55+
56+
pyarrow stops reading at the Arrow end-of-stream marker, which can leave
57+
bytes unconsumed at the HTTP layer (e.g. the terminating zero-length chunk
58+
of a chunked response). ``release_conn`` on a partially-read connection
59+
hands a tainted socket back to the pool — the next request to reuse it
60+
fails with ``http.client.ResponseNotReady`` ("Request-sent"). Draining any
61+
unread bytes first makes the connection safe to reuse.
62+
"""
63+
try:
64+
response.drain_conn()
65+
finally:
66+
response.release_conn()
67+
68+
5369
def _import_pyarrow() -> Any:
5470
try:
5571
import pyarrow.ipc as ipc # type: ignore[import-untyped]
@@ -95,7 +111,7 @@ def get_result_arrow(
95111
try:
96112
return ipc.open_stream(response).read_all()
97113
finally:
98-
response.release_conn()
114+
_release_conn(response)
99115

100116
@contextmanager
101117
def stream_result_arrow(
@@ -108,8 +124,10 @@ def stream_result_arrow(
108124
) -> Iterator["pa.RecordBatchStreamReader"]:
109125
"""Yield a :class:`pyarrow.RecordBatchStreamReader` for a ready result.
110126
111-
The HTTP connection is released when the context exits. Iterate the
112-
reader to consume :class:`pyarrow.RecordBatch` messages, or call
127+
The HTTP connection is drained and released when the context exits, so
128+
exiting early (before the reader is exhausted) reads and discards the
129+
remaining body to keep the connection reusable. Iterate the reader to
130+
consume :class:`pyarrow.RecordBatch` messages, or call
113131
``reader.read_all()`` for a full :class:`pyarrow.Table`.
114132
115133
Example::
@@ -127,7 +145,7 @@ def stream_result_arrow(
127145
try:
128146
yield ipc.open_stream(response)
129147
finally:
130-
response.release_conn()
148+
_release_conn(response)
131149

132150
def _call_arrow(
133151
self,

test-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ tox >= 3.9.0
44
flake8 >= 4.0.0
55
types-python-dateutil >= 2.8.19.14
66
mypy >= 1.5
7+
# pyarrow backs the `arrow` extra. Required here (not just an optional extra) so
8+
# the arrow integration scenarios actually run in CI instead of silently
9+
# skipping via importorskip. Keep the floor in sync with pyproject's extra.
10+
pyarrow >= 14

tests/integration/test_managed_tables_lifecycle.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,41 @@
55
66
1. declare a schema and a table on the database's default catalog connection,
77
2. upload a small parquet file,
8-
3. load it into the table (load_managed_table),
9-
4. read get_table_profile,
10-
5. refresh the catalog metadata,
11-
6. purge_table_cache,
12-
7. delete the managed table.
8+
3. load it into the table (load_managed_table) and verify the load response,
9+
4. delete the managed table.
10+
11+
Notes on managed-catalog semantics (all confirmed against prod). A managed
12+
catalog rejects the maintenance ops that apply to external catalogs, so this
13+
scenario deliberately omits them:
14+
15+
* No `refresh` step — rejected with 400 on a managed catalog ("use the loads
16+
endpoint to update its data"); `load_managed_table` is itself the load.
17+
* No `purge_table_cache` step — rejected with 400 ("purge not supported for
18+
managed catalogs").
19+
* No `get_table_profile` step — the profile is not populated within a usable
20+
window after a load, and no API call (refresh and purge are rejected; there
21+
is no profile-build/scan endpoint) triggers it. The load is verified via the
22+
`load_managed_table` response (row_count etc.), not a profile read.
1323
1424
The scratch_database fixture tears the database (and its catalog) down, so the
15-
test touches no seeded data. Skipped if pyarrow is unavailable (needed to author
16-
the parquet payload).
25+
test touches no seeded data. pyarrow is a hard test dependency (see
26+
test-requirements.txt) and is imported directly — a missing pyarrow must fail
27+
loudly, never silently skip this scenario in CI.
1728
"""
1829

1930
from __future__ import annotations
2031

2132
import io
2233

23-
import pytest
24-
25-
pa = pytest.importorskip("pyarrow")
26-
pq = pytest.importorskip("pyarrow.parquet")
34+
import pyarrow as pa
35+
import pyarrow.parquet as pq
2736

2837
from hotdata.api.connections_api import ConnectionsApi
2938
from hotdata.api.databases_api import DatabasesApi
30-
from hotdata.api.refresh_api import RefreshApi
3139
from hotdata.api.uploads_api import UploadsApi
3240
from hotdata.models.add_managed_schema_request import AddManagedSchemaRequest
3341
from hotdata.models.add_managed_table_request import AddManagedTableRequest
3442
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
35-
from hotdata.models.refresh_request import RefreshRequest
3643

3744

3845
def _parquet_bytes() -> bytes:
@@ -46,7 +53,6 @@ def test_managed_tables_lifecycle(
4653
databases_api: DatabasesApi,
4754
connections_api: ConnectionsApi,
4855
uploads_api: UploadsApi,
49-
refresh_api: RefreshApi,
5056
scratch_database: str,
5157
) -> None:
5258
# The database's auto-provisioned default catalog is a managed catalog,
@@ -77,15 +83,5 @@ def test_managed_tables_lifecycle(
7783
assert loaded.table_name == table_name
7884
assert loaded.row_count == 3
7985

80-
profile = connections_api.get_table_profile(connection_id, schema_name, table_name)
81-
assert profile.var_schema == schema_name
82-
assert profile.table == table_name
83-
assert profile.row_count == 3
84-
85-
# Refresh the catalog metadata for the managed connection.
86-
refreshed = refresh_api.refresh(RefreshRequest(connection_id=connection_id))
87-
assert refreshed.actual_instance is not None
88-
89-
# purge_table_cache and delete_managed_table both return None on success.
90-
connections_api.purge_table_cache(connection_id, schema_name, table_name)
86+
# delete_managed_table returns None on success.
9187
connections_api.delete_managed_table(connection_id, schema_name, table_name)

tests/integration/test_results_arrow.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55
that Arrow IPC content negotiation works end-to-end and that the streaming
66
variant yields the same data.
77
8-
Skipped if pyarrow is not installed (the helper requires the ``arrow`` extra).
8+
pyarrow is a hard test dependency (see test-requirements.txt), so this imports
9+
it directly rather than via importorskip — a missing pyarrow must fail loudly,
10+
never silently skip this scenario in CI.
911
"""
1012

1113
from __future__ import annotations
1214

1315
import time
1416

17+
import pyarrow as pa
1518
import pytest
1619

17-
pa = pytest.importorskip("pyarrow")
18-
1920
from hotdata.api.query_api import QueryApi
2021
from hotdata.api.query_runs_api import QueryRunsApi
2122
from hotdata.arrow import ResultsApi
@@ -52,7 +53,14 @@ def test_results_arrow(
5253
QueryRequest(
5354
var_async=True,
5455
async_after_ms=1000,
55-
sql="SELECT 1 AS x, 'hello' AS msg UNION ALL SELECT 2, 'world'",
56+
# ORDER BY so row order is deterministic — UNION ALL alone does not
57+
# guarantee it, and the column/pagination asserts below depend on it.
58+
sql=(
59+
"SELECT x, msg FROM ("
60+
"SELECT 1 AS x, 'hello' AS msg "
61+
"UNION ALL SELECT 2, 'world'"
62+
") ORDER BY x"
63+
),
5664
),
5765
x_database_id=database_id,
5866
)

tests/test_arrow.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ class _FakeUrllib3Response(io.RawIOBase):
4545
4646
pyarrow.ipc.open_stream wants a real file-like object (it checks
4747
``closed`` and ``readable()``); the SDK's RESTResponse needs ``status``,
48-
``reason``, ``data``, and ``headers``. release_conn is recorded.
48+
``reason``, ``data``, and ``headers``. drain_conn / release_conn are
49+
recorded so tests can assert the connection is drained before reuse.
4950
"""
5051

5152
def __init__(self, status: int, body: bytes, headers: Dict[str, str]):
@@ -56,6 +57,7 @@ def __init__(self, status: int, body: bytes, headers: Dict[str, str]):
5657
self._buf = io.BytesIO(body)
5758
self.headers = headers
5859
self.release_conn_called = False
60+
self.drain_conn_called = False
5961

6062
@property
6163
def data(self) -> bytes:
@@ -71,6 +73,9 @@ def read(self, amt: Optional[int] = -1) -> bytes:
7173
def readable(self) -> bool:
7274
return True
7375

76+
def drain_conn(self) -> None:
77+
self.drain_conn_called = True
78+
7479
def release_conn(self) -> None:
7580
self.release_conn_called = True
7681

@@ -146,6 +151,9 @@ def test_get_result_arrow_returns_table(monkeypatch: pytest.MonkeyPatch) -> None
146151
got = results.get_result_arrow("res_123")
147152

148153
assert got.equals(table)
154+
# Connection is drained (not just released) so a partially-read body can't
155+
# poison the pool and break the next request with ResponseNotReady.
156+
assert fake.drain_conn_called
149157
assert fake.release_conn_called
150158

151159
# Single request was made.
@@ -197,7 +205,8 @@ def test_stream_result_arrow_yields_reader(
197205
roundtrip = pa.Table.from_batches(batches, schema=reader.schema)
198206
assert roundtrip.equals(table)
199207

200-
# Connection is released after the context exits.
208+
# Connection is drained and released after the context exits.
209+
assert fake.drain_conn_called
201210
assert fake.release_conn_called
202211

203212

0 commit comments

Comments
 (0)