Skip to content

the same calls, awaited - #16

Merged
tamnd merged 1 commit into
mainfrom
aio
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
aio

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

zudb.aio is the client on an event loop. A statement runs inside Rust with the GIL down and comes back when it comes back, so one called straight from a coroutine stops the loop for that whole time, including the tasks answering requests that have nothing to do with the database. Each connection here gets a thread of its own and every call that could wait is handed to it and awaited.

import asyncio

import zudb.aio


async def main():
    async with zudb.aio.connect("social.zu1") as conn:
        await conn.execute("INSERT (p:person {uid: 1, name: 'ada'})")
        rows = await conn.execute("MATCH (p:person) RETURN p.name AS name")
        for (name,) in rows:
            print(name)


asyncio.run(main())

A thread per connection

Rather than a pool shared between them, because a connection is one lock and statements on it queue anyway. A pool would add no parallelism the engine can use and would let two statements written one after the other run in the other order. Two connections do run at the same time, since the engine puts the GIL down for the work, and two statements together cost 1.09 times what one costs alone on this machine.

What is awaited and what is not

What comes back is a zudb.Result, which is rows already in memory, so reading them is the call it was: for row in rows and rows.to_arrow() are not awaited. Only the ways in are, and only the ones that can wait. path, read_only, closed, rows_read and interrupt() are answered from beside the lock rather than through it, so they stay properties and a progress bar drawn from rows_read still reads while the statement it measures runs.

in_transaction() and registered() are methods where the sync client has properties, because both answers live behind the lock and a property that gave back a coroutine would read as if conn.in_transaction:, which is true whatever the answer.

connect(), transaction() and appender() all reach the engine, so all three are coroutines, and a coroutine is not a context manager. Each returns something that is both: awaiting it opens the thing and async with opens it and enters it, so the two spellings a caller might reach for are the same call.

Cancellation

Cancelling the task that awaits a statement interrupts the statement. The engine is asked to stop and the coroutine does not return until it has, so the connection is idle again by the time the CancelledError reaches the caller rather than busy with work nobody is waiting for. Which statement gets stopped is guarded, so a cancellation either interrupts its own statement or interrupts nothing, and never lands on the one after it. A statement still queued when the cancellation arrives is dropped without running. A transaction block cancelled partway is a block that raised, so it leaves through the rollback.

Not imported until asked for

The module pulls in asyncio and a thread pool, and a script that never awaits anything should pay for neither, so the package does not import it. import zudb is still inside its budget and there is now a test that says asyncio is not in sys.modules afterwards.

Tests

25 in tests/test_aio.py, run with asyncio.run since the suite has no async plugin. The three claims are each checked directly: the loop ticks more than twenty times through one statement, statements arrive in the order they were awaited, and the statement after a cancelled one starts at once rather than queueing behind the work that was stopped.

The long statement runs over a loaded throng rather than a hundred and fifty inserted people, because in a release build the smaller one is over in eleven milliseconds and there is nothing left to cancel.

The README gains a section, and it is a section that runs: the rule for which blocks are whole programs now reads "starts with an import" rather than "starts with import zudb", so the async example is executed like the other two.

Local gate is green: ruff check, ruff format, and the full pytest suite in a release build.

zudb.aio gives every connection a thread of its own and hands it every
call that could wait. A statement is inside Rust with the GIL down, so
one run straight from a coroutine stops the loop for as long as it
takes, including the tasks answering requests that have nothing to do
with the database.

A thread per connection rather than a pool shared between them,
because a connection is one lock and statements on it queue anyway. A
pool would add no parallelism the engine can use and would let two
statements written one after the other run in the other order. Two
connections do run at the same time, and two statements together cost
1.09 times what one costs alone.

What comes back is a zudb.Result, which is rows already in memory, so
reading them is not awaited and never was. Only the ways in are, and
only the ones that can wait: path, read_only, closed, rows_read and
interrupt stay properties, answered from beside the lock, so a
progress bar still reads while the statement it measures runs.

Cancelling the task that awaits a statement interrupts the statement
and waits for it to stop, so the connection is idle by the time the
CancelledError arrives rather than busy with work nobody wants. A
statement still queued is dropped without running, and a transaction
block cancelled partway leaves through the rollback.

The module is a submodule to ask for by name, not one the package
imports, so a script that never awaits anything pays for neither
asyncio nor the thread pool, and there is a test that says so.
@tamnd
tamnd merged commit c651bda into main Aug 18, 2026
6 of 8 checks passed
@tamnd
tamnd deleted the aio branch August 18, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant