informix-db/tests/test_transactions.py
Ryan Malloy 1c19c71cb6 Phase 7: real transaction semantics on logged databases
Introduces driver-managed transactions that work seamlessly across
logged and unlogged databases. The user calls commit() and rollback()
without needing to know which kind they're hitting — the connection
tracks transaction state internally.

Three protocol facts came out of integration testing:

1. Logged DBs in non-ANSI mode require an explicit SQ_BEGIN before
   the first DML — the server doesn't auto-open a transaction.
   Connection._ensure_transaction() sends SQ_BEGIN lazily and is
   idempotent within an open txn. After commit/rollback, the next
   DML triggers a fresh BEGIN.

2. SQ_RBWORK has a [short savepoint=0] payload before the SQ_EOT
   framing tag — sending SQ_RBWORK alone causes the server to hang
   silently (waiting for the missing 2 bytes). SQ_CMMTWORK has no
   payload. This is the same pattern as the SHORT-vs-INT bug from
   Phase 4.x and the 2-byte length prefix from Phase 6.c — when the
   server hangs, it's an incomplete PDU body.

3. SQ_XACTSTAT (tag 99) is a logged-DB-only message that's
   interleaved with normal responses. Now drained in all four
   response-reading paths: cursor _drain_to_eot, _read_describe_
   response, _read_fetch_response, and connection _drain_to_eot.

For unlogged DBs (e.g., sysmaster), SQ_BEGIN returns -201 and we
cache that result so subsequent DML doesn't re-probe. commit() and
rollback() are silent no-ops in that case — same client code works
across both DB modes.

Tests:
* New tests/test_transactions.py — 10 integration tests covering
  commit visibility, rollback isolation, multi-row rollback, partial
  commit-then-rollback, autocommit behavior, cross-connection
  durability, UPDATE/DELETE rollback, implicit per-statement txn.
* conftest.py auto-creates testdb (logged) for the suite.
* Two old tests rewritten to assert new no-op behavior on unlogged
  DBs (test_commit_rollback_in_unlogged_db_is_noop,
  test_commit_in_unlogged_db_is_noop).

Total: 53 unit + 98 integration = 151 tests.

The Phase 3 "gate test" (test_rollback_hides_insert) — a rolled-back
INSERT must be invisible to subsequent SELECTs in the same session —
now passes against a real logged database for the first time.
2026-05-04 12:54:02 -06:00

296 lines
9.7 KiB
Python

"""Phase 7 integration tests — real transaction semantics on a logged DB.
These tests run against ``testdb`` (created with ``WITH LOG`` — see
``conftest.py::_ensure_testdb``). Without a logged database, ``COMMIT``
and ``ROLLBACK`` are no-ops, so the canonical "rolled-back insert is
invisible" test would silently lie.
The tests cover:
- Autocommit on/off semantics
- Single-connection commit visibility
- Rollback isolation (the gate test from the original Phase 3 plan)
- Cross-connection isolation (committed data visible to a fresh conn,
uncommitted data not)
- Multi-statement transactions
"""
from __future__ import annotations
import contextlib
from collections.abc import Iterator
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def _connect(params: ConnParams, *, autocommit: bool = False) -> informix_db.Connection:
return informix_db.connect(
host=params.host,
port=params.port,
user=params.user,
password=params.password,
database=params.database,
server=params.server,
connect_timeout=10.0,
read_timeout=10.0,
autocommit=autocommit,
)
@pytest.fixture
def fresh_table(logged_db_params: ConnParams) -> Iterator[str]:
"""Create a fresh test table per test and drop it on teardown.
Uses a permanent table (not TEMP) because TEMP tables are session-
scoped and don't participate in cross-connection isolation tests.
"""
table = "t_txn_test"
with _connect(logged_db_params, autocommit=True) as conn:
cur = conn.cursor()
with contextlib.suppress(Exception):
cur.execute(f"DROP TABLE {table}")
cur.execute(f"CREATE TABLE {table} (id INTEGER, label VARCHAR(64))")
try:
yield table
finally:
with _connect(logged_db_params, autocommit=True) as conn:
cur = conn.cursor()
with contextlib.suppress(Exception):
cur.execute(f"DROP TABLE {table}")
# -------- Commit + rollback ---------------------------------------------
def test_committed_insert_persists(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""Canonical commit test: COMMIT makes data permanent."""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "alpha")
)
conn.commit()
cur.execute(f"SELECT id, label FROM {fresh_table}")
assert cur.fetchall() == [(1, "alpha")]
def test_rollback_hides_insert(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""Canonical rollback test (the Phase 3 gate test).
A rolled-back INSERT must be invisible to subsequent SELECTs in the
same session. This is the load-bearing transaction guarantee.
"""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "alpha")
)
conn.rollback()
cur.execute(f"SELECT COUNT(*) FROM {fresh_table}")
(count,) = cur.fetchone()
assert int(count) == 0
def test_rollback_after_multiple_inserts(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""ROLLBACK reverts ALL pending statements, not just the most recent."""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.executemany(
f"INSERT INTO {fresh_table} VALUES (?, ?)",
[(1, "a"), (2, "b"), (3, "c"), (4, "d")],
)
conn.rollback()
cur.execute(f"SELECT COUNT(*) FROM {fresh_table}")
(count,) = cur.fetchone()
assert int(count) == 0
def test_partial_commit_then_rollback(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""Committed data stays; subsequent uncommitted data rolls back cleanly."""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
# Phase 1: commit some rows
cur.executemany(
f"INSERT INTO {fresh_table} VALUES (?, ?)",
[(1, "kept-a"), (2, "kept-b")],
)
conn.commit()
# Phase 2: insert more, but roll back
cur.executemany(
f"INSERT INTO {fresh_table} VALUES (?, ?)",
[(3, "lost-a"), (4, "lost-b")],
)
conn.rollback()
cur.execute(
f"SELECT id, label FROM {fresh_table} ORDER BY id"
)
assert cur.fetchall() == [(1, "kept-a"), (2, "kept-b")]
# -------- Autocommit ---------------------------------------------------
def test_autocommit_persists_without_explicit_commit(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""With autocommit=True, no explicit commit() is needed."""
with _connect(logged_db_params, autocommit=True) as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "auto")
)
# No conn.commit() call — autocommit handles it
# Verify in a fresh connection that the row is durable
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.execute(f"SELECT id, label FROM {fresh_table}")
assert cur.fetchall() == [(1, "auto")]
def test_autocommit_rollback_is_noop(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""In autocommit mode, rollback() can't undo already-committed work.
DB-API 2.0 spec is silent on the exact behavior here, but the
pragmatic behavior is that rollback() in autocommit mode is a no-op
— there's no open transaction to roll back. The data persists.
"""
with _connect(logged_db_params, autocommit=True) as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "x")
)
# Try to roll back — should be a no-op since each statement
# was already committed. Some drivers raise; either is OK.
with contextlib.suppress(Exception):
conn.rollback()
cur.execute(f"SELECT COUNT(*) FROM {fresh_table}")
(count,) = cur.fetchone()
assert int(count) == 1
# -------- Cross-connection isolation -----------------------------------
def test_committed_data_visible_to_fresh_connection(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""Committed writes in conn A are durably visible to a fresh conn B.
This is the "data is durable across connection boundaries"
guarantee. We don't test the *uncommitted-invisible* direction
here because Informix's default isolation level (Committed Read
with row-level locking) makes conn B *block* on uncommitted writes
rather than reading zero — which surfaces as -252 lock-timeout
errors that depend on `lock mode wait` config rather than on
transaction semantics. The blocking behavior is correct; testing
it here would just be testing Informix's lock manager.
"""
conn_a = _connect(logged_db_params)
try:
cur_a = conn_a.cursor()
cur_a.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "writer")
)
conn_a.commit()
finally:
conn_a.close()
# Fresh connection — should see the committed row
with _connect(logged_db_params) as conn_b:
cur_b = conn_b.cursor()
cur_b.execute(f"SELECT id, label FROM {fresh_table}")
assert cur_b.fetchall() == [(1, "writer")]
# -------- Mixed DML + sanity -------------------------------------------
def test_update_inside_transaction(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""UPDATE within a transaction is reverted by ROLLBACK."""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "original")
)
conn.commit()
# Update + roll back
cur.execute(
f"UPDATE {fresh_table} SET label = ? WHERE id = ?",
("modified", 1),
)
conn.rollback()
cur.execute(f"SELECT label FROM {fresh_table} WHERE id = ?", (1,))
assert cur.fetchone() == ("original",)
def test_delete_inside_transaction(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""DELETE within a transaction is reverted by ROLLBACK."""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
cur.executemany(
f"INSERT INTO {fresh_table} VALUES (?, ?)",
[(1, "a"), (2, "b"), (3, "c")],
)
conn.commit()
cur.execute(f"DELETE FROM {fresh_table} WHERE id = ?", (2,))
conn.rollback()
cur.execute(f"SELECT COUNT(*) FROM {fresh_table}")
(count,) = cur.fetchone()
assert int(count) == 3
def test_implicit_transaction_per_dml(
logged_db_params: ConnParams, fresh_table: str
) -> None:
"""The driver implicitly opens a transaction on each DML.
Users don't need (and shouldn't use) ``BEGIN WORK`` in SQL —
the driver sends ``SQ_BEGIN`` automatically before the first DML
in non-autocommit mode. After ``commit()``, the next DML opens
a fresh transaction. This test verifies the round-trip of two
independent transactions on the same connection.
"""
with _connect(logged_db_params) as conn:
cur = conn.cursor()
# Transaction 1
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (1, "txn-1")
)
conn.commit()
# Transaction 2 — completely separate, opens fresh BEGIN
cur.execute(
f"INSERT INTO {fresh_table} VALUES (?, ?)", (2, "txn-2")
)
conn.commit()
cur.execute(f"SELECT id, label FROM {fresh_table} ORDER BY id")
assert cur.fetchall() == [(1, "txn-1"), (2, "txn-2")]