Skip to content

fix!: run query() in a READ ONLY transaction, as documented - #64

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/56-read-only-query
Oct 16, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
fix/56-read-only-query

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Closes #56.

Problem

query() was documented as "read-only ... does not start a transaction", and rule 9 called an advisory lock taken in it "a no-op". Measured on PostgreSQL 17, the block is an ordinary autobegun transaction from its first statement until it exits. It holds its locks, sits idle in transaction between statements, and a write followed by qx.session.commit() is persisted.

Change

  • query() makes its transaction READ ONLY. When the session hasn't checked out a connection yet, that happens through session.connection(execution_options={"postgresql_readonly": True}), so asyncpg opens the transaction READ ONLY with no extra round trip, and the dialect resets the flag when the connection returns to the pool (covered by a test on a one-connection pool). When open_session() has already started the transaction (an isolation level was applied, or an override ran a statement, like the TenantUnitOfWork example in its docstring), the block issues SET TRANSACTION READ ONLY, which PostgreSQL accepts at that point.
  • Docstrings (uow/sqlalchemy.py, uow/protocols.py), the advanced guide and docs/agents.md describe the block as what it is: one read-only transaction, rolled back on exit, that holds its locks and its now() until then. Rule 5 is rewritten. Rule 9 now says the lock is held for the whole block. The method table and the WRONG/RIGHT example are corrected, and there's a new "Fixed since" row.

Checked before choosing: pg_try_advisory_xact_lock, set_config(..., true) (the search_path listener) and SET LOCAL all work in a READ ONLY transaction. nextval() doesn't.

Considered and rejected

  • Wording only. It keeps every caller working, but "read-only" would keep depending on the caller, and the issue was about the guarantee.
  • A session after_begin listener that marks every transaction in the block, including one started after an explicit commit(). It costs a round trip per block on the common path, to cover a misuse (committing inside query()) that the docstring now names.

Breaking change

fix!:. Code that writes through query(), including nextval() or a volatile function that writes, now fails with SQLSTATE 25006 (ReadOnlySQLTransactionError) instead of being silently rolled back, or persisted after an explicit commit. Migration: move the write to transaction(). The commit carries a BREAKING CHANGE: footer, so release-please will list it, and it bumps the minor (0.5.0) under bump-minor-pre-major. No org repo pins sqlalchemy-foundation-kit (I grepped every sibling pyproject.toml), so no cap trips.

Evidence

New integration tests against the old uow/sqlalchemy.py (swapped in from origin/master): 4 failed, 4 passed. The failures are the reporter's INSERT + commit, transaction_read_only inside the block, the isolation-level path, and the open_session override path. The 4 that pass on both are regression guards for behaviour the new docs describe: the advisory lock is held until exit, the pooled connection is read-write again afterwards, and two read paths.

The reporter's script after the fix: lines 1–2 unchanged (they describe a transaction, as the docs now say), and step 3 raises:

1. inside query(): server state = 'idle in transaction', now() = statement_timestamp(): False, locks still held on notes: ['AccessShareLock']
2. advisory lock in query(): acquired=True; another session's attempt meanwhile=False
sqlalchemy.exc.DBAPIError: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.ReadOnlySQLTransactionError'>: cannot execute INSERT in a read-only transaction

Also fixed: a telemetry unit test whose AsyncMock session made the sync in_transaction() return a coroutine.

Gate: make check clean; make test 632 passed.

@codecov

codecov Bot commented Oct 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

BREAKING CHANGE: uow.query() now opens its transaction READ ONLY. A write
inside it (INSERT, UPDATE, DELETE, DDL, nextval()) fails with SQLSTATE 25006
instead of being rolled back silently, or persisted when followed by an explicit
session.commit(). Move writes to uow.transaction().
@AlexeyShalaev
AlexeyShalaev force-pushed the fix/56-read-only-query branch from e9b700a to a4a3feb Compare October 16, 2026 19:08
@AlexeyShalaev
AlexeyShalaev merged commit cb77404 into master Oct 16, 2026
8 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/56-read-only-query branch October 16, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

query() is documented as read-only and transaction-free, but it holds a transaction for the whole block and lets writes commit

1 participant