Content. The types page now warns about the two framing bugs this release fixed, since both silently corrupt data on versions before it: a BLOB or CLOB in any position but last shifted every column after it, and a NULL collection did the same. The API page documents two things that are now user-visible: only one scrollable cursor may be open per connection (previously the second statement drew -285 and destroyed the cursor as collateral), and transaction control written as SQL is tracked (previously rollback() after a SQL BEGIN WORK sent nothing and reported success). The phase log gains a section on the review itself, including the pattern behind almost all eleven bugs. Test counts updated to 457/456. Style. 136 em-dashes across 22 content files, plus Hero.astro and two stylesheets, rewritten rather than substituted -- swapping the character for a comma leaves prose that reads like it lost an argument with a linter. Bullets that used a dash to gloss a term now use a colon; parenthetical asides became their own sentences or moved inside brackets. The rendered HTML is clean. Verified against the live site by content, not status code: these pages return 200 for every path and render the 404 body, so a stale deploy looks perfectly healthy.
62 lines
4.6 KiB
Plaintext
62 lines
4.6 KiB
Plaintext
---
|
||
title: Async strategy
|
||
description: Why informix-driver wraps a sync core in a thread pool instead of going fully async, and what that costs.
|
||
sidebar:
|
||
order: 4
|
||
---
|
||
|
||
import { Aside } from '@astrojs/starlight/components';
|
||
|
||
`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.
|
||
|
||
## What we considered
|
||
|
||
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.
|
||
|
||
## Why option 2 was the right call
|
||
|
||
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.
|
||
|
||
## What it costs
|
||
|
||
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.
|
||
|
||
## What it doesn't cost
|
||
|
||
- **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.
|
||
|
||
## When option 1 might still be worth it
|
||
|
||
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.
|
||
|
||
<Aside type="note">
|
||
This is a Phase 16 decision. The pivot from "rewrite as async-native" to "wrap the sync core" is documented in [`docs/DECISION_LOG.md`](https://git.supported.systems/warehack.ing/informix-db/src/branch/main/docs/DECISION_LOG.md). Three years from now, if it turns out we should have gone with option 1, we have a clear path.
|
||
</Aside>
|