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.
296 lines
9.7 KiB
Python
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")]
|