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 conn.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, one async. Most code duplicated. Worst of both worlds.

We picked option 2.

For typical database workloads — request-scoped connections, mostly waiting 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 — 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.