Skip to content

The phase log

The driver was built across 39+ phases, each with a focused scope and a decision log. This page is a high-level index; the gory details (with rationale, alternatives considered, and rollback notes) live in docs/DECISION_LOG.md.

PhaseTitleOutcome
1Socket + minimal SQ_INFOFirst handshake against the dev container
2–4Login, DBOPEN, error decodingCan connect and select a database
5Statement executionSELECT 1 works
6Parameter binding?-placeholders, basic types
7Logged-DB transactionsDiscovered Informix needs explicit SQ_BEGIN per tx in non-ANSI mode
8BYTE / TEXT (legacy in-row blobs)Needs blobspace
9Scrollable cursorsSQ_SCROLL PDU, position semantics
10/11Smart-LOB read & writeArchitectural pivot to SQ_FILE intercept; ~3× smaller than projected
PhaseTitleOutcome
12Type system overhaulPer-column readers (predecessor to Phase 37)
13DECIMAL / MONEY exact precisiondecimal.Decimal round-trip
14DATETIME range typingReturns date / datetime / time per field range
15INTERVAL typesCustom IntervalYM, timedelta for D-to-F
16Async APIPivot to thread-pool wrapping (~250 lines) instead of full async refactor (~2000 lines)
17Connection pool (sync)min/max sizing, acquire timeout, max idle
18Connection pool (async)Mirror of sync API on aio.Pool
19TLS supportBring-your-own-context, tls=True for dev
20Locale / Unicodeclient_locale, full mapping in Connection.encoding
PhaseTitleOutcome
21Type-checking passpy.typed, full mypy/pyright coverage
22Error code mappingSQLCODE → exception per reference
23Health checksPool validates idle connections before return
24Statement cachingPer-connection prepared-statement cache
25Fast-path call (SQ_FPROUTINE)Direct UDF/SPL invocation, bypassing PREPARE
26CRITICALPool returned connections with open transactions (fixed)
27CRITICALPer-connection wire lock + async cancellation safety
28HIGH_raise_sq_err bare-except masking wire desync (fixed)
29Cursor finalizersServer-side resource leak on mid-fetch raise (fixed)
30Hardening pass5 medium-severity audit findings, all closed

After Phase 30: 0 critical, 0 high, 0 medium audit findings remain. Driver is production-ready.

PhaseTitleResult
31Statement cache LRU tuningBetter hit rate on repeated queries
32Cursor lifecycle optimizationFewer round-trips on small queries
33Pipelined executemany1.6× faster than IfxPy on bulk inserts
34LRU caches for type lookupRemoved dispatch overhead on hot paths
35Memory profile passIdentified 100k-row baseline
36IfxPy comparison harnessEstablished the 2.4× bulk-fetch gap
37Per-column reader strategy−10% on bulk SELECT, ratio → 2.10×
38exec()-based row-decoder codegenFurther −12%, ratio → 2.04×
39Connection-scoped buffered reader−32% on bulk SELECT, ratio → 1.05–1.15×

The Phase 37–39 trajectory is documented in detail at The buffered reader →, including the architectural mistake the first pass got wrong.

Field reports and systematic review (2026.08–2026.09)

Section titled “Field reports and systematic review (2026.08–2026.09)”

Phase 30’s audit found nothing left. Then real schemas arrived, and the audit’s clean bill of health turned out to be a statement about the questions it had asked rather than about the driver.

A field report from a user running Informix 12 surfaced three type-framing bugs in a single afternoon: INT8 / SERIAL8 never decoded at all, NCHAR losing its first character, and BOOLEAN corrupting every column after it. Fuzzing the type matrix found more, and each one had the same shape as the last.

2026.09.02 went back over the driver looking for that shape rather than for new symptoms, and found eleven bugs.

AreaWhat was wrong
Row framingNothing ever checked that a row consumed its own payload. Fourteen framing bugs had reached users, every one detectable for free
Smart LOBsRead as a flat 72-byte field when they occupy 149, shifting every column after a non-final BLOB
CompositesA NULL collection skipped its length field, the same bug as NULL LVARCHAR twelve lines away
Transactionsrollback() after a SQL BEGIN WORK sent nothing and reported success
ClassificationComments, CTEs and parenthesized selects were run as DML and failed with -260
Scrollable cursorsA second statement on the connection got -285 and destroyed the cursor too
Statement releaseTwo of eight exits leaked a failed statement, bricking the connection
Cursor finalizersCleanup could land inside another statement, and the lock probe was blind to its own thread
AsyncThe whole layer shared the process-wide thread pool, so cancellations starved it
SocketTwo readers on one stream agreed only by accident of how the server replies

The pattern behind almost all of them: a hazard that was understood and guarded at the site where it was first observed, rather than at the abstraction that owned it. The guard then never travelled to its siblings. Four separate hand-written copies of the same UDT envelope, three of them wrong. Six hand-rolled copies of the same statement-release cleanup, two missing entirely.

Two of the fixes came from asking the server instead of guessing. statement_type and statement_id had both been parsed out of every DESCRIBE response into a metadata dict that nothing read, while the code that needed them inferred the answer from the first word of the SQL and got it wrong five ways.

The cheapest fix was also the most valuable. Asserting that a row decoder lands exactly on the end of its payload costs one integer comparison, and it would have caught all fourteen framing bugs at the byte where each happened rather than three releases later in somebody’s result set.

Test count went from 241 to 457 across three server versions over this stretch.

Two more, one of each kind.

The :N placeholder rewriter was a bare regular expression, so it rewrote the inside of string literals: 'http://host:8080/x' was stored as 'http://host?/x'. Same family as the statement-classification bug above, and the last place the driver inferred meaning from SQL text instead of handling it. Replaced with a scanner using Informix’s own lexical rules, measured rather than assumed. One of those rules would have been got wrong from habit: a backslash escapes nothing, so 'a\'b' is an unterminated string and draws -282. A scanner written to Postgres reflexes desyncs there and corrupts everything after it.

row_factory=Row adds column-name and attribute access, on request from a user coming from pyodbc. Opt-in, because it costs about 9% on bulk fetch and that is not a trade to make for someone who never reads a column by name.

Testing the second one found the pattern again, this time in code written an hour earlier. The names a column could shadow were hand-listed as count and index, the two tuple methods; then keys, _asdict and _fields were added to the same class and the list was never revisited. A column called keys returned a bound method, in silence. The fix was to compute the set from the class rather than lengthen the list, and to parametrize the test over that computed set so it grows with the class.

The interesting part is where testing found it. A differential test across twenty columns covering every awkward type, populated and fully NULL, found nothing. The bug was in six lines of name bookkeeping that looked obviously correct, and had been correct when written. Anything that must stay in sync with a class by hand eventually will not.

The decision log calls out four moments where the obvious choice would have been wrong:

  1. Phase 10/11: abandoning SQ_FPROUTINE + SQ_LODATA for SQ_FILE intercept. Smaller, simpler, same correctness.
  2. Phase 16: thread-pool async instead of a full async refactor. ~88% less code, same FastAPI surface.
  3. Phase 27: adding a per-connection wire lock instead of relying on PEP 249’s “don’t share connections” advice. Made accidental sharing safe rather than catastrophic.
  4. Phase 39: buffer on the connection, not on the reader. Got it wrong on the first pass; the bug surfaced as a hang on pipelined executemany. Fixed in ten minutes once the architectural mistake was named.

The roadmap (loose, not committed):

  • Phase 40+ (codec): Numpy-backed bulk decode for homogeneous columns. ~5× speedup target on analytical workloads.
  • Phase 4x (protocol): Optional Cython acceleration for the codec hot loop. Would compromise “pure Python”, so it would sit behind a build flag.
  • Phase 5x (API): Native callproc with named parameters, IBM-specific scrollable cursor extensions for full IfxPy parity.

The phase log is updated as work lands. The repo’s CHANGELOG.md is the source of truth for shipped changes.