|
1 | 1 | #!/usr/bin/env python |
2 | 2 | from __future__ import annotations |
3 | 3 |
|
| 4 | +import gzip |
| 5 | +import json |
4 | 6 | from collections import namedtuple |
| 7 | +from concurrent.futures import ThreadPoolExecutor, as_completed |
5 | 8 | from http import HTTPStatus |
6 | 9 | from test.helpers import create_mock_response |
7 | 10 | from unittest import mock |
|
40 | 43 | try: |
41 | 44 | from snowflake.connector.compat import TOO_MANY_REQUESTS |
42 | 45 | from snowflake.connector.errors import TooManyRequests |
43 | | - from snowflake.connector.result_batch import MAX_DOWNLOAD_RETRY, JSONResultBatch |
44 | | - from snowflake.connector.vendored import requests # NOQA |
| 46 | + from snowflake.connector.result_batch import ( |
| 47 | + MAX_DOWNLOAD_RETRY, |
| 48 | + JSONResultBatch, |
| 49 | + RemoteChunkInfo, |
| 50 | + _ensure_decompressed, |
| 51 | + ) |
| 52 | + from snowflake.connector.vendored import requests |
45 | 53 |
|
46 | 54 | SESSION_FROM_REQUEST_MODULE_PATH = ( |
47 | 55 | "snowflake.connector.vendored.requests.sessions.Session" |
48 | 56 | ) |
49 | 57 | except ImportError: |
50 | 58 | MAX_DOWNLOAD_RETRY = None |
51 | 59 | JSONResultBatch = None |
| 60 | + RemoteChunkInfo = None |
| 61 | + _ensure_decompressed = None |
52 | 62 | SESSION_FROM_REQUEST_MODULE_PATH = "requests.sessions.Session" |
53 | 63 | TooManyRequests = None |
54 | 64 | TOO_MANY_REQUESTS = None |
@@ -151,3 +161,184 @@ def test_retries_until_success(): |
151 | 161 | assert res.raw == "success" |
152 | 162 | # call `get` once for each error and one last time when it succeeds |
153 | 163 | assert mock_get.call_count == len(error_codes) + 1 |
| 164 | + |
| 165 | + |
| 166 | +# --------------------------------------------------------------------------- |
| 167 | +# Gzip decompression fallback tests |
| 168 | +# |
| 169 | +# These reproduce the JSONDecodeError observed when cloud storage serves |
| 170 | +# result-set chunks as raw gzip blobs *without* a Content-Encoding: gzip |
| 171 | +# header. urllib3 v2 only triggers transparent decompression when that |
| 172 | +# header is present, so the raw \x1f\x8b bytes leak into response.text. |
| 173 | +# --------------------------------------------------------------------------- |
| 174 | + |
| 175 | + |
| 176 | +def _make_gzip_json_rows(*rows): |
| 177 | + """Encode rows as Snowflake-style comma-separated JSON and gzip-compress.""" |
| 178 | + payload = ",\n".join(json.dumps(row) for row in rows) |
| 179 | + return gzip.compress(payload.encode("utf-8")) |
| 180 | + |
| 181 | + |
| 182 | +def _make_gzip_response(compressed_body: bytes): |
| 183 | + """Build a fake requests.Response whose .content is raw gzip bytes. |
| 184 | +
|
| 185 | + This simulates what happens when cloud storage returns gzip data |
| 186 | + without setting Content-Encoding: gzip -- the requests/urllib3 stack |
| 187 | + skips decompression and .content returns the raw compressed bytes. |
| 188 | + """ |
| 189 | + resp = requests.Response() |
| 190 | + resp.status_code = 200 |
| 191 | + resp._content = compressed_body |
| 192 | + resp.headers["Content-Type"] = "application/json" |
| 193 | + return resp |
| 194 | + |
| 195 | + |
| 196 | +@pytest.mark.skipif(JSONResultBatch is None, reason="vendored requests unavailable") |
| 197 | +class TestGzipDecompressionFallback: |
| 198 | + """Verify _ensure_decompressed fixes responses that were not decoded by urllib3.""" |
| 199 | + |
| 200 | + def test_ensure_decompressed_unpacks_gzip_content(self): |
| 201 | + """_ensure_decompressed should replace raw gzip bytes with decompressed content.""" |
| 202 | + rows = [["Alice", 30], ["Bob", 25]] |
| 203 | + raw_gz = _make_gzip_json_rows(*rows) |
| 204 | + assert raw_gz[:2] == b"\x1f\x8b", "sanity: payload is gzip" |
| 205 | + |
| 206 | + resp = _make_gzip_response(raw_gz) |
| 207 | + assert resp.content[:2] == b"\x1f\x8b", "before fix: content is raw gzip" |
| 208 | + |
| 209 | + _ensure_decompressed(resp) |
| 210 | + |
| 211 | + assert resp.content[:2] != b"\x1f\x8b", "after fix: gzip magic gone" |
| 212 | + recovered = json.loads("[" + resp.content.decode("utf-8") + "]") |
| 213 | + assert recovered == rows |
| 214 | + |
| 215 | + def test_ensure_decompressed_leaves_plain_json_alone(self): |
| 216 | + """_ensure_decompressed should be a no-op for already-decoded responses.""" |
| 217 | + plain = b'["Alice", 30],\n["Bob", 25]' |
| 218 | + resp = requests.Response() |
| 219 | + resp.status_code = 200 |
| 220 | + resp._content = plain |
| 221 | + original_id = id(resp._content) |
| 222 | + |
| 223 | + _ensure_decompressed(resp) |
| 224 | + |
| 225 | + assert resp.content is plain or resp.content == plain |
| 226 | + assert id(resp._content) == original_id |
| 227 | + |
| 228 | + def test_json_result_batch_load_with_gzip_response(self): |
| 229 | + """JSONResultBatch._load should succeed even when the HTTP layer didn't decompress.""" |
| 230 | + rows = [["val1", 1], ["val2", 2], ["val3", 3]] |
| 231 | + raw_gz = _make_gzip_json_rows(*rows) |
| 232 | + |
| 233 | + resp = _make_gzip_response(raw_gz) |
| 234 | + _ensure_decompressed(resp) |
| 235 | + |
| 236 | + batch = JSONResultBatch( |
| 237 | + rowcount=len(rows), |
| 238 | + chunk_headers=None, |
| 239 | + remote_chunk_info=None, |
| 240 | + schema=[], |
| 241 | + column_converters=[], |
| 242 | + use_dict_result=False, |
| 243 | + ) |
| 244 | + loaded = batch._load(resp) |
| 245 | + assert loaded == rows |
| 246 | + |
| 247 | + def test_concurrent_multichunk_download_with_gzip_responses(self): |
| 248 | + """Reproduce the reported issue: concurrent ThreadPoolExecutor downloads |
| 249 | + where each chunk response is raw gzip (no Content-Encoding header). |
| 250 | +
|
| 251 | + Without the _ensure_decompressed fix, json.loads() in _load() would |
| 252 | + receive \\x1f\\x8b... garbage and raise JSONDecodeError. |
| 253 | + """ |
| 254 | + num_chunks = 6 |
| 255 | + rows_per_chunk = 50 |
| 256 | + chunks_data = {} |
| 257 | + url_to_response = {} |
| 258 | + |
| 259 | + for chunk_idx in range(num_chunks): |
| 260 | + rows = [ |
| 261 | + [f"chunk{chunk_idx}_row{r}", chunk_idx * 100 + r] |
| 262 | + for r in range(rows_per_chunk) |
| 263 | + ] |
| 264 | + chunk_url = f"http://fake-s3.example.com/results/chunk_{chunk_idx}" |
| 265 | + body = _make_gzip_json_rows(*rows) |
| 266 | + chunks_data[chunk_idx] = rows |
| 267 | + url_to_response[chunk_url] = _make_gzip_response(body) |
| 268 | + |
| 269 | + batches = [] |
| 270 | + for chunk_idx in range(num_chunks): |
| 271 | + chunk_url = f"http://fake-s3.example.com/results/chunk_{chunk_idx}" |
| 272 | + body = url_to_response[chunk_url].content |
| 273 | + batch = JSONResultBatch( |
| 274 | + rowcount=rows_per_chunk, |
| 275 | + chunk_headers=None, |
| 276 | + remote_chunk_info=RemoteChunkInfo( |
| 277 | + url=chunk_url, uncompressedSize=0, compressedSize=len(body) |
| 278 | + ), |
| 279 | + schema=[], |
| 280 | + column_converters=[], |
| 281 | + use_dict_result=False, |
| 282 | + ) |
| 283 | + batches.append((chunk_idx, batch)) |
| 284 | + |
| 285 | + def mock_get(url, **kwargs): |
| 286 | + return url_to_response[url] |
| 287 | + |
| 288 | + def fetch_batch(idx_and_batch): |
| 289 | + idx, batch = idx_and_batch |
| 290 | + response = batch._download() |
| 291 | + return idx, batch._load(response) |
| 292 | + |
| 293 | + all_results = {} |
| 294 | + with mock.patch( |
| 295 | + SESSION_FROM_REQUEST_MODULE_PATH + ".get", side_effect=mock_get |
| 296 | + ): |
| 297 | + with ThreadPoolExecutor(max_workers=4) as pool: |
| 298 | + futures = [pool.submit(fetch_batch, ib) for ib in batches] |
| 299 | + for future in as_completed(futures): |
| 300 | + chunk_idx, loaded = future.result() |
| 301 | + all_results[chunk_idx] = loaded |
| 302 | + |
| 303 | + assert len(all_results) == num_chunks |
| 304 | + for chunk_idx in range(num_chunks): |
| 305 | + assert all_results[chunk_idx] == chunks_data[chunk_idx], ( |
| 306 | + f"Chunk {chunk_idx}: expected valid JSON rows but got corrupted data. " |
| 307 | + f"This indicates gzip decompression was not applied." |
| 308 | + ) |
| 309 | + |
| 310 | + def test_concurrent_multichunk_with_session_manager_clone(self): |
| 311 | + """End-to-end reproduction using a cloned SessionManager, mirroring the |
| 312 | + real download path where result batches use a cloned manager without |
| 313 | + connection pooling. |
| 314 | + """ |
| 315 | + from snowflake.connector.session_manager import SessionManager |
| 316 | + |
| 317 | + base_manager = SessionManager() |
| 318 | + cloned_manager = base_manager.clone(use_pooling=False) |
| 319 | + |
| 320 | + rows = [["hello", 42], ["world", 99]] |
| 321 | + raw_gz = _make_gzip_json_rows(*rows) |
| 322 | + chunk_url = "http://fake-s3.example.com/results/chunk_0" |
| 323 | + |
| 324 | + batch = JSONResultBatch( |
| 325 | + rowcount=len(rows), |
| 326 | + chunk_headers={}, |
| 327 | + remote_chunk_info=RemoteChunkInfo( |
| 328 | + url=chunk_url, uncompressedSize=0, compressedSize=len(raw_gz) |
| 329 | + ), |
| 330 | + schema=[], |
| 331 | + column_converters=[], |
| 332 | + use_dict_result=False, |
| 333 | + session_manager=cloned_manager, |
| 334 | + ) |
| 335 | + |
| 336 | + resp = _make_gzip_response(raw_gz) |
| 337 | + |
| 338 | + with mock.patch( |
| 339 | + SESSION_FROM_REQUEST_MODULE_PATH + ".request", return_value=resp |
| 340 | + ): |
| 341 | + response = batch._download() |
| 342 | + |
| 343 | + loaded = batch._load(response) |
| 344 | + assert loaded == rows |
0 commit comments