Skip to content

Async strategy

informix-driver’s async API (from informix_db import aio) is implemented by wrapping the sync core in a thread pool. Every await cur.execute(...) schedules the underlying sync execute() on the pool’s executor.

This is a deliberate architectural choice from Phase 16. Here’s the reasoning.

Three options for adding async support to a sync database driver:

  1. Full async I/O refactor. Rewrite the protocol layer on top of asyncio.Protocol or asyncio.StreamReader. The codec, framing, and connection state all become coroutines. ~2000 lines of code, full test rewrite.

  2. Thread-pool wrapping. Keep the sync core. Wrap each public method with loop.run_in_executor(). ~250 lines of code, sync tests still apply, no protocol-layer changes.

  3. Dual implementations. Maintain two parallel code paths, one sync and one async. Most code duplicated. Worst of both worlds.

We picked option 2.

For typical database workloads, meaning request-scoped connections that mostly wait on I/O, the practical difference between option 1 and option 2 is small:

  • Latency: option 1 has a slight edge (no thread context switch), but the difference is dwarfed by the actual database round-trip (~80 µs LAN, ~ms WAN). For a single query, option 2 adds ~5–10 µs of executor overhead.
  • Throughput under concurrency: option 1 wins when you have N coroutines on M physical cores with M < N. The thread pool needs to context-switch between threads; the async loop just runs the next coroutine. For 10–100 concurrent FastAPI requests on a 4-core box, this difference is small.
  • Code complexity: option 1 is dramatically harder to write and test. The protocol layer becomes asynchronous everywhere; cancellation paths multiply; the shape of “what does a partial PDU read look like” becomes a state machine instead of a while not done: read_more().

For a driver that needs to be production-ready in finite engineering time, option 2 was the right call.

The honest costs:

  • One worker thread per concurrent in-flight query. With 100 concurrent queries, you have 100 threads. This is fine for I/O-bound work (Python releases the GIL during socket reads) but doesn’t scale beyond a few hundred concurrent queries on a single process.
  • Thread-pool sizing matters. The default executor size (5 × CPU count) is fine for most workloads. For high-concurrency workloads, you may want a larger executor.
  • Cancellation requires thought. A cancelled await cur.execute() cancels the coroutine, but the worker thread continues running until the syscall returns. The connection is marked dirty until then. Phase 27 made this safe, in that cancelled workers cannot leak onto recycled pool connections, but the underlying syscall does still complete.
  • Cancellation safety. This was the original concern. Phase 27’s per-connection wire lock + worker reaping makes async cancellation cancellation-safe in the same sense asyncpg is.
  • FastAPI integration. The aio.Pool is a drop-in replacement for any “async database pool” pattern. Depends(get_conn) works exactly as you’d expect.
  • Async generator support. async for row in cur works. The fetch is per-row chunked through the executor; the iteration shape is async-native.

The two scenarios where a full async I/O implementation would matter:

  1. Very high concurrency on a single process (1000+ in-flight queries). Thread context-switching cost becomes measurable. We haven’t hit this in practice.
  2. Sub-millisecond query latencies on a unloaded server. The 5–10 µs executor overhead is a meaningful percentage. For typical Informix workloads where round-trip is ~80 µs+, it isn’t.

If either becomes a real production concern, the layered architecture lets us swap in a fully-async lower half without changing the upper half. The cursor / connection / pool API doesn’t care how the bytes get to and from the server. That’s the option-2 win we explicitly preserved.