asynch: asyncio Driver for ClickHouse

repository·dev·Indexed 42 days ago

https://github.com/long2ice/asynch

A high-performance, asyncio-native Python driver for ClickHouse (v0.4.0) that implements the PEP 249 interface. It uses Cython to optimize the protocol hot path and provides native TCP support, a built-in connection pool via the Pool class, and support for streaming large result sets and JSON columns (ClickHouse 24.8+).

Tokens
12.4K
Snippets
18
Records
25
Agent score
53%

What's inside asynch

  1. Work with JSON columns

    dev

    For ClickHouse 24.8+, JSON columns are supported. When reading, they are returned as nested Python dictionaries. When inserting, you can provide either a Python dictionary or a JSON-formatted string.

    async def use_json(conn: Connection):
        async with conn.cursor() as cursor:
            await cursor.execute(
                "CREATE TABLE test.events (id UInt32, doc JSON) ENGINE = MergeTree ORDER BY id"
            )
            await cursor.execute(
                "INSERT INTO test.events (id, doc) VALUES",
                [
                    (1, {"user": {"name": "ada"}, "tags": ["a", "b"]}),
                    (2, '{"user": {"name": "bob"}}'),   # JSON text works too
                ],
            )
    
            await cursor.execute("SELECT doc FROM test.events ORDER BY id")
            assert await cursor.fetchone() == ({"user": {"name": "ada"}, "tags": ["a", "b"]},)
  2. Install asynch

    dev

    Install the asynch driver using pip. Binary wheels are available for Linux, Windows, and macOS (Intel/ARM) on Python 3.11–3.14. If a wheel is not available, a C toolchain is required for compilation.

    To enable transport compression, install with the [compression] extra, which requires clickhouse-cityhash.

    > pip install asynch
    
    # With compression support
    > pip install asynch[compression]
  3. Stream large result sets

    dev

    To avoid loading massive result sets into memory at once, enable streaming on the cursor. This allows you to iterate over rows block-by-block from the server using async for.

    async def stream_rows(conn: Connection):
        async with conn.cursor() as cursor:
            # Enable streaming with a specific max_row_buffer
            cursor.set_stream_results(stream_results=True, max_row_buffer=65536)
            await cursor.execute("SELECT number FROM system.numbers LIMIT 1000000")
            async for row in cursor:
                process(row)
  4. How the Pool manages connection liveness and idling

    dev

    The Pool implements several mechanisms to ensure connection health and resource efficiency:

    Liveness Checks

    To avoid the latency of extra PING round-trips in production, the pool uses a liveness_grace period. If a connection was verified within this grace period, the pool skips the is_live() check during checkout. Otherwise, it performs a liveness check before handing the connection to the user.

    Idle Connection Reaping

    If idle_timeout is configured, the pool periodically reaps connections that have been idle for longer than the timeout. The reaping process ensures that the number of free connections does not drop below minsize.

    Connection Discarding

    Connections are automatically discarded (closed) if:

    1. They fail a liveness check during checkout.
    2. They are found to be dead during the release process.
    3. They exceed the idle_timeout during a reaping cycle.
  5. Access query statistics via Connection.last_query

    dev

    The Connection.last_query attribute provides metadata about the most recent query executed on that connection, such as elapsed time and progress.

    async def show_stats(conn: Connection):
        async with conn.cursor() as cursor:
            await cursor.execute("SELECT number FROM system.numbers LIMIT 100000")
            await cursor.fetchall()
    
        stats = conn.last_query
        print(stats.elapsed, stats.progress.rows, stats.progress.bytes)
  6. Manage connections with Pool

    dev

    The Pool class provides a built-in connection pool for managing multiple connections efficiently.

    Key features:

    • Initialization: Use async with Pool(...) to automatically manage the pool lifecycle, or call await pool.startup() and await pool.shutdown() manually.
    • Acquisition: Use async with pool.connection() as conn: to acquire a connection from the pool.
    • Sizing: minsize defines the number of connections to open on startup; maxsize defines the upper limit.
    • Idle Timeout: Use idle_timeout (in seconds) to release connections that have been idle for too long, down to the minsize limit.
    from asynch import Pool
    
    # Using Pool as an async context manager
    async def use_pool():
        async with Pool(dsn="clickhouse://127.0.0.1:9000", minsize=1, maxsize=10) as pool:
            async with pool.connection() as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute("SELECT 1")
                    ret = await cursor.fetchone()
                    assert ret == (1,)
    
    # Manual management with idle timeout
    async def manual_pool():
        pool = Pool(dsn="clickhouse://127.0.0.1:9000", minsize=2, maxsize=20, idle_timeout=60)
        await pool.startup()
        # ... logic ...
        await pool.shutdown()
  7. Execute SQL and fetch results with Cursor

    dev

    Acquire a Cursor from a Connection instance to execute SQL statements. By default, conn.cursor() returns a standard Cursor which returns rows as tuples.

    Common methods:

    • execute(query, parameters=None): Executes the SQL statement.
    • fetchone(): Fetches the next single row.
    • fetchall(): Fetches all remaining rows as a list of tuples.
    # Fetching one row
    async with conn.cursor() as cursor:
        await cursor.execute("SELECT 1")
        ret = await cursor.fetchone()
        assert ret == (1,)
    
    # Fetching all rows
    async with conn.cursor() as cursor:
        await cursor.execute("SELECT 1")
        ret = await cursor.fetchall()
        assert ret == [(1,)]
  8. Use DictCursor for dictionary-based results

    dev

    To receive query results as a sequence of dictionaries (where keys are column names) instead of tuples, pass DictCursor to the cursor() method. This is useful for mapping rows to structured data or inserting data using dictionaries.

    from asynch.cursors import DictCursor
    
    # Fetching results as dicts
    async def dict_cursor(conn: Connection):
        async with conn.cursor(cursor=DictCursor) as cursor:
            await cursor.execute("SELECT 1")
            ret = await cursor.fetchall()
            assert ret == [{"1": 1}]
    
    # Inserting data using dicts
    async def insert_dict(conn: Connection):
        async with conn.cursor(cursor=DictCursor) as cursor:
            await cursor.execute(
                "INSERT INTO test.asynch(id,decimal,date,datetime,float,uuid,string,ipv4,ipv6) VALUES",
                [
                    {
                        "id": 1,
                        "decimal": 1,
                        "date": "2020-08-08",
                        "datetime": "2020-08-08 00:00:00",
                        "float": 1,
                        "uuid": "59e182c4-545d-4f30-8b32-cefea2d0d5ba",
                        "string": "1",
                        "ipv4": "0.0.0.0",
                        "ipv6": "::",
                    }
                ],
            )
  9. Cancel a running query

    dev

    You can stop a long-running query from another task using conn.cancel() or cursor.cancel(). This leaves the connection usable for subsequent operations.

    import asyncio
    
    async def cancel_slow_query(conn: Connection):
        async with conn.cursor() as cursor:
            # Start a long query in a separate task
            task = asyncio.create_task(cursor.execute("SELECT count() FROM numbers(20000000000)"))
            await asyncio.sleep(1)
            
            # Cancel the query via the connection or cursor
            await conn.cancel() 
            
            # The task will return with whatever the server had sent before cancellation
            await task
  10. Connect to ClickHouse using Connection

    dev

    You can establish a connection to a ClickHouse server using the Connection class in two ways:

    1. DSN String: Use a connection string in the format clickhouse://[user:password]@host:port/database. If a DSN is provided, it takes priority over individual parameters.
    2. DSN Parameters: Provide user, password, host, port, and database as separate arguments.

    Always use async with to ensure the connection is properly closed.

    from asynch import Connection
    
    # Using a DSN string
    async def connect_with_dsn():
        async with Connection(dsn="clickhouse://ch_user:P%4055w0rD@127.0.0.1:9000/chdb") as conn:
            pass
    
    # Using separate parameters
    async def connect_with_params():
        async with Connection(
            user="ch_user",
            password="P@55w0rD",
            host="127.0.0.1",
            port=9000,
            database="chdb",
        ) as conn:
            pass
  11. Manage Connection Lifecycle and Status

    dev

    You can manually control the connection state and inspect its current status.

    Methods:

    • await connect(): Opens the connection.
    • await close(): Closes the connection. If already closed, this is a no-op.
    • await ping(): Checks if the connection is alive. Raises ConnectionError if the ping fails.
    • await is_live(): Returns True if the connection is opened and responds to a ping. Unlike ping(), it does not raise exceptions and is suitable for connection pool management.
    • await cancel(): Asks the server to stop the currently running query. This should be called from a different task than the one awaiting the query. Returns True if a query was cancelled.

    Properties:

    • opened (bool | None): True if the connection is currently open.
    • closed (bool): True if the connection has been closed.
    • status (str): Returns the current ConnectionStatus (e.g., created, opened, or closed). Raises ConnectionError if the state is unresolved.
  12. Initialize and manage connections with the Pool class

    dev

    The Pool class manages a collection of asynchronous Connection objects. It allows you to maintain a minimum number of connections (minsize) and a maximum limit (maxsize) to optimize resource usage and performance.

    Key Configuration Options

    • minsize (int): The minimum number of connections to keep open in the pool. Defaults to constants.POOL_MIN_SIZE.
    • maxsize (int): The maximum number of connections allowed in the pool. Defaults to constants.POOL_MAX_SIZE.
    • idle_timeout (float | None): If set, connections idle longer than this value will be closed (reaped), down to the minsize. If None, the pool keeps all connections ever opened (historical behavior).
    • liveness_grace (float): The time window (in seconds) during which a recently verified connection can be reused without an extra PING round-trip. Defaults to 1.0.
    • **kwargs: Passed directly to the Connection constructor.

    Lifecycle Management

    Use the async with context manager to automatically handle startup() (filling the pool to minsize) and shutdown() (closing all connections).

    from asynch.pool import Pool
    
    async def main():
        # Initialize pool with specific constraints
        async with Pool(minsize=5, maxsize=20, idle_timeout=60.0) as pool:
            # Use the pool here
            pass
    
    import asyncio
    asyncio.run(main())