mirror of
https://github.com/hanzo-ds/connect.git
synced 2026-10-30 20:31:24 +00:00
* migrate from pylint to ruff * ruff format/lint switch over * add formatting commit * update changelog
445 lines
12 KiB
Python
445 lines
12 KiB
Python
import asyncio
|
|
import gzip
|
|
import time
|
|
import zlib
|
|
from unittest.mock import Mock
|
|
|
|
import lz4.frame
|
|
import pytest
|
|
import zstandard
|
|
|
|
from clickhouse_connect.driver.exceptions import OperationalError
|
|
from clickhouse_connect.driver.streaming import (
|
|
StreamingInsertSource,
|
|
StreamingResponseSource,
|
|
)
|
|
|
|
|
|
class MockAsyncIterator:
|
|
"""Mock async iterator for simulating aiohttp response content."""
|
|
|
|
def __init__(self, chunks):
|
|
self.chunks = chunks
|
|
self.index = 0
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
if self.index >= len(self.chunks):
|
|
raise StopAsyncIteration
|
|
chunk = self.chunks[self.index]
|
|
self.index += 1
|
|
return chunk
|
|
|
|
|
|
class MockContent:
|
|
"""Mock aiohttp StreamReader content."""
|
|
|
|
def __init__(self, chunks):
|
|
self.chunks = chunks
|
|
self.index = 0
|
|
|
|
async def read(self, n=-1):
|
|
"""Mock read method that returns chunks sequentially."""
|
|
if self.index >= len(self.chunks):
|
|
return b""
|
|
chunk = self.chunks[self.index]
|
|
self.index += 1
|
|
return chunk
|
|
|
|
|
|
class MockResponse:
|
|
"""Mock aiohttp ClientResponse."""
|
|
|
|
def __init__(self, chunks, encoding=None):
|
|
self.content = MockContent(chunks)
|
|
self.headers = {"Content-Encoding": encoding} if encoding else {}
|
|
self.status = 200
|
|
self.closed = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_basic_streaming_no_compression():
|
|
"""Test basic streaming without compression."""
|
|
chunks = [b"hello ", b"world", b"!"]
|
|
response = MockResponse(chunks)
|
|
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return result
|
|
|
|
result = await loop.run_in_executor(None, consume)
|
|
|
|
assert result == chunks
|
|
assert b"".join(result) == b"hello world!"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_with_gzip_compression():
|
|
"""Test streaming with gzip decompression."""
|
|
original_data = b"hello world! " * 1000
|
|
compressed = gzip.compress(original_data)
|
|
chunk_size = 100
|
|
chunks = [compressed[i : i + chunk_size] for i in range(0, len(compressed), chunk_size)]
|
|
|
|
response = MockResponse(chunks, encoding="gzip")
|
|
source = StreamingResponseSource(response, encoding="gzip")
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return b"".join(result)
|
|
|
|
decompressed = await loop.run_in_executor(None, consume)
|
|
|
|
assert decompressed == original_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_with_deflate_compression():
|
|
"""Test streaming with deflate decompression."""
|
|
original_data = b"test data " * 500
|
|
compressed = zlib.compress(original_data)
|
|
|
|
chunks = [compressed[i : i + 50] for i in range(0, len(compressed), 50)]
|
|
|
|
response = MockResponse(chunks, encoding="deflate")
|
|
source = StreamingResponseSource(response, encoding="deflate")
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return b"".join(result)
|
|
|
|
decompressed = await loop.run_in_executor(None, consume)
|
|
|
|
assert decompressed == original_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_with_zstd_compression():
|
|
"""Test streaming with zstd decompression."""
|
|
original_data = b"zstd test data " * 500
|
|
compressor = zstandard.ZstdCompressor()
|
|
compressed = compressor.compress(original_data)
|
|
|
|
chunks = [compressed[i : i + 50] for i in range(0, len(compressed), 50)]
|
|
|
|
response = MockResponse(chunks, encoding="zstd")
|
|
source = StreamingResponseSource(response, encoding="zstd")
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return b"".join(result)
|
|
|
|
decompressed = await loop.run_in_executor(None, consume)
|
|
|
|
assert decompressed == original_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_with_lz4_compression():
|
|
"""Test streaming with lz4 decompression."""
|
|
original_data = b"lz4 test data " * 500
|
|
compressed = lz4.frame.compress(original_data)
|
|
|
|
chunks = [compressed[i : i + 50] for i in range(0, len(compressed), 50)]
|
|
|
|
response = MockResponse(chunks, encoding="lz4")
|
|
source = StreamingResponseSource(response, encoding="lz4")
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return b"".join(result)
|
|
|
|
decompressed = await loop.run_in_executor(None, consume)
|
|
|
|
assert decompressed == original_data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_stream():
|
|
"""Test streaming with empty response."""
|
|
response = MockResponse([])
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return result
|
|
|
|
result = await loop.run_in_executor(None, consume)
|
|
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_large_chunk():
|
|
"""Test streaming with single large chunk."""
|
|
large_chunk = b"x" * 1000000
|
|
response = MockResponse([large_chunk])
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return result
|
|
|
|
result = await loop.run_in_executor(None, consume)
|
|
|
|
assert len(result) == 1
|
|
assert result[0] == large_chunk
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_many_small_chunks():
|
|
"""Test streaming with many small chunks."""
|
|
chunks = [f"chunk{i}".encode() for i in range(1000)]
|
|
response = MockResponse(chunks)
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
result.append(chunk)
|
|
return result
|
|
|
|
result = await loop.run_in_executor(None, consume)
|
|
|
|
assert len(result) == 1000
|
|
assert result == chunks
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generator_caching():
|
|
"""Test that .gen property returns cached generator."""
|
|
response = MockResponse([b"test"])
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
# Access .gen multiple times - should return same generator
|
|
gen1 = source.gen
|
|
gen2 = source.gen
|
|
|
|
assert gen1 is gen2, "Generator should be cached"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_producer_error_propagation():
|
|
"""Test that producer errors are propagated to consumer."""
|
|
|
|
class FailingContent:
|
|
@staticmethod
|
|
async def read(n=-1):
|
|
raise ValueError("Producer error!")
|
|
|
|
response = Mock()
|
|
response.content = FailingContent()
|
|
response.headers = {}
|
|
response.closed = False
|
|
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
try:
|
|
for _ in source.gen:
|
|
pass
|
|
except OperationalError as e:
|
|
return str(e)
|
|
return "No error raised!"
|
|
|
|
error_msg = await loop.run_in_executor(None, consume)
|
|
|
|
assert error_msg == "Failed to read response data from server"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gzip_with_incremental_decompression():
|
|
"""Test that gzip decompression works incrementally with streaming."""
|
|
original_data = b"The quick brown fox jumps over the lazy dog. " * 100
|
|
compressed = gzip.compress(original_data)
|
|
|
|
# Split compressed data into very small chunks to force incremental decompression
|
|
chunks = [compressed[i : i + 10] for i in range(0, len(compressed), 10)]
|
|
|
|
response = MockResponse(chunks, encoding="gzip")
|
|
source = StreamingResponseSource(response, encoding="gzip")
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
def consume():
|
|
"""Consume and verify we get multiple decompressed chunks."""
|
|
chunks_received = []
|
|
for chunk in source.gen:
|
|
chunks_received.append(chunk)
|
|
return chunks_received, b"".join(chunks_received)
|
|
|
|
chunks_received, decompressed = await loop.run_in_executor(None, consume)
|
|
|
|
assert decompressed == original_data
|
|
assert len([c for c in chunks_received if c]) > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backpressure_with_bounded_queue():
|
|
"""Test that bounded queue provides backpressure."""
|
|
# Create many chunks to test backpressure
|
|
chunks = [f"chunk{i}".encode() for i in range(100)]
|
|
response = MockResponse(chunks)
|
|
|
|
source = StreamingResponseSource(response, encoding=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
await source.start_producer(loop)
|
|
|
|
# Slow consumer
|
|
def slow_consume():
|
|
result = []
|
|
for chunk in source.gen:
|
|
time.sleep(0.001)
|
|
result.append(chunk)
|
|
return result
|
|
|
|
result = await loop.run_in_executor(None, slow_consume)
|
|
|
|
# All chunks should still be received despite slow consumer
|
|
assert len(result) == 100
|
|
assert result == chunks
|
|
|
|
|
|
class MockTransform:
|
|
"""Mock NativeTransform."""
|
|
|
|
def __init__(self, chunks=None):
|
|
self.chunks = chunks or [b"chunk1", b"chunk2"]
|
|
|
|
def build_insert(self, context):
|
|
yield from self.chunks
|
|
|
|
|
|
class FailingTransform:
|
|
"""Mock NativeTransform that raises error."""
|
|
|
|
@staticmethod
|
|
def build_insert(context):
|
|
yield b"chunk1"
|
|
raise ValueError("Serialization error")
|
|
|
|
|
|
class MockContext:
|
|
"""Mock InsertContext."""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_insert_basic():
|
|
"""Test basic streaming insert (reverse bridge)."""
|
|
transform = MockTransform()
|
|
context = MockContext()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
source = StreamingInsertSource(transform, context, loop)
|
|
source.start_producer()
|
|
|
|
chunks = []
|
|
async for chunk in source.async_generator():
|
|
chunks.append(chunk)
|
|
|
|
await source.close()
|
|
|
|
assert chunks == [b"chunk1", b"chunk2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_insert_error_propagation():
|
|
"""Test that insert producer errors are propagated to async consumer."""
|
|
transform = FailingTransform()
|
|
context = MockContext()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
source = StreamingInsertSource(transform, context, loop)
|
|
source.start_producer()
|
|
|
|
chunks = []
|
|
with pytest.raises(ValueError, match="Serialization error"):
|
|
async for chunk in source.async_generator():
|
|
chunks.append(chunk)
|
|
|
|
await source.close()
|
|
|
|
# Should have received first chunk before error
|
|
assert chunks == [b"chunk1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_insert_backpressure():
|
|
"""Test backpressure in streaming insert."""
|
|
chunks = [f"chunk{i}".encode() for i in range(100)]
|
|
transform = MockTransform(chunks)
|
|
context = MockContext()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# Small queue size to force backpressure
|
|
source = StreamingInsertSource(transform, context, loop, maxsize=2)
|
|
source.start_producer()
|
|
|
|
received = []
|
|
async for chunk in source.async_generator():
|
|
received.append(chunk)
|
|
# Yield to allow producer to run (since we're in same loop/process)
|
|
await asyncio.sleep(0.001)
|
|
|
|
await source.close()
|
|
|
|
assert len(received) == 100
|
|
assert received == chunks
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|