Summary
Cursor / AsyncCursor capture connection._tx_context at the moment
connection.cursor() is called and hold that reference for the whole
lifetime of the cursor. This diverges from how DB-API drivers like
psycopg2 / asyncpg behave, where a cursor is a lightweight handle
that reads the connection's current transaction state on every
execute. The current behavior produces silent wrong results or errors
in fairly natural usage patterns.
See Connection.cursor() in ydb_dbapi/connections.py (passes
tx_context=self._tx_context into the cursor constructor) and
Cursor.__init__ in ydb_dbapi/cursors.py where
self._tx_context = tx_context is stored as a snapshot and then used on
every execute.
Repro / affected scenarios
Assume conn.set_isolation_level(SERIALIZABLE) so interactive
transactions are on.
1. Cursor created before begin() silently runs outside the transaction
cur = conn.cursor() # snapshot: tx_context = None
conn.begin()
cur.execute("INSERT INTO t ...") # runs in autocommit, NOT in the tx
conn.commit() # commits an empty tx; the insert is
# already persisted autonomously
No error is raised. The user thinks the insert was transactional; it
wasn't.
2. Reusing a cursor after commit() / rollback() breaks it
conn.begin()
cur = conn.cursor()
cur.execute("INSERT ...")
conn.commit() # tx_context is now dead
cur.execute("SELECT ...") # cursor still references the closed
# tx_context → SDK-level failure
In psycopg2 the equivalent just works — the cursor picks up the new
transaction automatically.
3. Mixed creation/execute ordering gives inconsistent state
cur1 = conn.cursor() # snapshot None
conn.begin()
cur2 = conn.cursor() # snapshot = current tx_context
cur1.execute(...) # outside the tx
cur2.execute(...) # inside the tx
Two cursors on the same connection end up in different transaction
contexts. Very hard to reason about.
Why this is surprising
The DB-API 2.0 ecosystem (psycopg2, asyncpg, sqlite3, pymysql, …)
treats the transaction as a property of the connection. Cursors are
lightweight handles that read the connection's current state on every
execute. Users and frameworks (SQLAlchemy, Django, Alembic) rely on
that model. The snapshot behavior here is an implementation detail that
leaks into user code.
Proposed fix
In Cursor.execute / AsyncCursor.execute read
self._connection._tx_context at execute time instead of using the
snapshot stored on the cursor. The snapshot field can stay for internal
bookkeeping but should not be the source of truth.
Existing tests should be audited for any assumption that relies on the
current snapshot-at-creation behavior; a quick scan didn't find any
intentional dependency.
Alternative (less invasive)
If changing semantics is undesirable, at minimum execute could
validate that the cursor's snapshot still matches
connection._tx_context and raise ProgrammingError with a clear
message when they diverge — no silent wrong behavior, but same friction
for users.
Summary
Cursor/AsyncCursorcaptureconnection._tx_contextat the momentconnection.cursor()is called and hold that reference for the wholelifetime of the cursor. This diverges from how DB-API drivers like
psycopg2/asyncpgbehave, where a cursor is a lightweight handlethat reads the connection's current transaction state on every
execute. The current behavior produces silent wrong results or errorsin fairly natural usage patterns.
See
Connection.cursor()inydb_dbapi/connections.py(passestx_context=self._tx_contextinto the cursor constructor) andCursor.__init__inydb_dbapi/cursors.pywhereself._tx_context = tx_contextis stored as a snapshot and then used onevery
execute.Repro / affected scenarios
Assume
conn.set_isolation_level(SERIALIZABLE)so interactivetransactions are on.
1. Cursor created before
begin()silently runs outside the transactionNo error is raised. The user thinks the insert was transactional; it
wasn't.
2. Reusing a cursor after
commit()/rollback()breaks itIn
psycopg2the equivalent just works — the cursor picks up the newtransaction automatically.
3. Mixed creation/execute ordering gives inconsistent state
Two cursors on the same connection end up in different transaction
contexts. Very hard to reason about.
Why this is surprising
The DB-API 2.0 ecosystem (
psycopg2,asyncpg,sqlite3,pymysql, …)treats the transaction as a property of the connection. Cursors are
lightweight handles that read the connection's current state on every
execute. Users and frameworks (SQLAlchemy, Django, Alembic) rely onthat model. The snapshot behavior here is an implementation detail that
leaks into user code.
Proposed fix
In
Cursor.execute/AsyncCursor.executereadself._connection._tx_contextat execute time instead of using thesnapshot stored on the cursor. The snapshot field can stay for internal
bookkeeping but should not be the source of truth.
Existing tests should be audited for any assumption that relies on the
current snapshot-at-creation behavior; a quick scan didn't find any
intentional dependency.
Alternative (less invasive)
If changing semantics is undesirable, at minimum
executecouldvalidate that the cursor's snapshot still matches
connection._tx_contextand raiseProgrammingErrorwith a clearmessage when they diverge — no silent wrong behavior, but same friction
for users.