Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Five nouns, in the order a request passes through them.
application is correct and intended.
* **A context manager on it opens exactly one session** and closes it on exit.
`transaction()` commits on success and rolls back on exception; `managed_session()`
hands you the session and commits nothing; `query()` starts no transaction of its own
and throws away anything you write.
hands you the session and commits nothing; `query()` runs one `READ ONLY` transaction
that is rolled back on exit, so a write inside it raises.
* **The transaction object** is what the context manager yields: your subclass of
`AsyncSQLAlchemyUowTransaction`, constructed with that session, exposing your
repositories as properties. `tx.session` is the `AsyncSession` underneath;
Expand Down Expand Up @@ -127,7 +127,7 @@ async def main() -> None:
async with uow.transaction() as tx: # commits on exit, rolls back on exception
await tx.users.add("user@example.com")

async with uow.query() as qx: # read only: writes here are discarded
async with uow.query() as qx: # READ ONLY: a write here raises (SQLSTATE 25006)
users = (await qx.session.execute(select(UserDB))).scalars().all()

await manager.aclose()
Expand Down Expand Up @@ -235,7 +235,7 @@ a wait histogram that never moves.
|---|---|---|---|---|
| `transaction(isolation_level=None, flush_before_commit=None)` | `session.begin()` | flush, then **commit** | rollback | `T` |
| `managed_session(isolation_level=None)` | `await session.begin()` | nothing — **you** commit | rollback | `(T, AsyncSession)` |
| `query(isolation_level=None)` | none started | nothing; session closes | session closes | `T` |
| `query(isolation_level=None)` | one `READ ONLY` transaction | rolled back; session closes | rolled back; session closes | `T` |
| `open_session(isolation_level=None)` | none | nothing | nothing | `AsyncSession` |

`open_session` is the documented extension point: override it in a subclass to set a GUC,
Expand Down Expand Up @@ -391,9 +391,13 @@ decorator.
4. **The commit belongs to the unit of work, never to a repository.** A repository
`flush()`es; `transaction()` commits. A `session.commit()` inside a repository ends the
transaction under everything else that was going to share it.
5. **`query()` throws writes away.** It starts no transaction and commits nothing, so the
session's implicit transaction is rolled back at close. An `INSERT` issued there is
silently gone — verified, not theorised. Use `transaction()` for anything that writes.
5. **`query()` is a read-only transaction.** It opens one `READ ONLY` transaction and rolls
it back on exit, so an `INSERT`, `UPDATE`, `DELETE`, DDL or `nextval()` inside it fails
with SQLSTATE 25006. Reads, `SET LOCAL` and advisory locks work. It is still a
transaction: the locks it takes (`AccessShareLock` on every table read) are held until
the block exits, `now()` is fixed at its first statement, and a slow `await` inside it
leaves the connection `idle in transaction`. Keep it short; use `transaction()` for
anything that writes. On 0.4.0 and earlier it was not read-only — see the table below.
6. **One session, one task.** The session a context manager yields is not concurrency-safe:
`asyncio.gather` over statements on the same `tx` raises
`IllegalStateChangeError`. Fan out by opening a `transaction()` per task, not by sharing
Expand All @@ -414,8 +418,8 @@ decorator.
new replicas holding different locks for the same name until it finishes.
9. **An advisory lock is only held while its transaction is.** `pg_try_advisory_xact_lock`
releases at transaction end, so take it inside `transaction()` or `managed_session()`
and do the protected work in the same block. Taking one in `query()` is a no-op with a
`True` return.
and do the protected work in the same block. One taken in `query()` is held until the
block exits, then released — it protects only what that read-only block does.
10. **`managed_session()` commits nothing.** Leaving the block without calling
`await session.commit()` rolls everything back at close, quietly. Use `transaction()`
unless the commit decision genuinely depends on something you learn after writing.
Expand Down Expand Up @@ -484,6 +488,7 @@ is what to do on the version the row names.
| `postgres_db_pool_overflow`, `postgres_db_pool_checked_out` (0.4.0 and earlier) | sampled from the `checkout` / `checkin` events: `overflow` exported `QueuePool.overflow()`, which starts at `-pool_size` (`-9` with one connection open of 10), and since `checkin` fires before the connection is back, an idle pool kept reporting its last burst as checked out | read `pool.checkedout()` and `max(pool.overflow(), 0)` yourself |
| `postgres_db_connection_timeouts_total` (0.4.0) | counted asyncpg statement timeouts — a slow query, on a healthy connection — and missed connect timeouts, which never reach `handle_error`. A database refusing connections moved no series at all | alert on `connection_errors_total{error_type="TimeoutError"}` for slow statements, and on the checkout wait for connections |
| `uow.transaction()` with a failing flush (0.4.0 and earlier, `flush_before_commit=True`, the default) | logged `str(exc)` at WARNING as `extra={"error": ...}` before re-raising: the statement's parameters and PostgreSQL's `DETAIL: Key (...)=(...)` row values went to the logs, and `hide_parameters=True` removed only the parameters | `flush_before_commit=False`; the same exception still reaches the caller |
| `uow.query()` (0.4.0 and earlier) | documented as read-only and transaction-free, but ran an ordinary autobegun transaction: a write followed by `qx.session.commit()` was persisted, and without the commit it was rolled back silently | do reads in `query()` and every write in `transaction()` |
| `postgres_db_connection_checkout_duration_seconds` (0.3.0 and earlier) | the only checkout histogram there was, and it measures the time between the `checkout` and `checkin` events — how long a connection was *held*, which is query duration seen from the pool. The time a caller waited for a connection, which is what the name suggests and what predicts a pool outage, was not exposed at all | none; the wait was not measurable from outside the pool |

`IsolationLevel` itself was always fine — `READ_UNCOMMITTED`, `READ_COMMITTED`,
Expand Down Expand Up @@ -512,7 +517,7 @@ class UserRepository:
```

```python
# WRONG — query() starts no transaction, so this INSERT is rolled back at close
# WRONG — query() is a READ ONLY transaction: this INSERT raises (SQLSTATE 25006)
async with uow.query() as qx:
await qx.users.add("user@example.com")

Expand Down
17 changes: 12 additions & 5 deletions docs/guide/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,26 +113,33 @@ async with uow.transaction(isolation_level="READ COMMITTED") as tx:

The level is set on the connection this block checks out, so it applies to this
transaction and leaves the engine alone. It has to be set before the transaction starts,
which means the block's transaction opens as the context manager is entered — including
in `query()`, which otherwise waits for the first statement. To set a level for every
which means the block's transaction opens as the context manager is entered. To set a level for every
session instead, pass `isolation_level=` to `AsyncSessionManager` or set
`QuerySettings.isolation_level`.

### Read-Only Queries

For read-only operations without transaction overhead:
`query()` runs the block in one `READ ONLY` transaction and rolls it back on exit:

```python
# Query context - no transaction management
async with uow.query() as qx:
users = await qx.users.list_all(limit=100)
# No commit/rollback, just queries
# Rolled back on exit; nothing to commit

# With isolation level
async with uow.query(isolation_level=IsolationLevel.REPEATABLE_READ) as qx:
users = await qx.users.list_all()
```

PostgreSQL enforces the `READ ONLY`: an `INSERT`, `UPDATE`, `DELETE`, DDL statement or
`nextval()` inside the block fails with SQLSTATE 25006. Reads, `SET LOCAL` /
`set_config` and advisory locks work. Being a transaction, the block holds every lock it
takes — the `AccessShareLock` on each table it read, which makes an `ALTER TABLE` on that
table wait — until it exits, fixes `now()` at its first statement, and leaves the
connection `idle in transaction` across any `await` that is not a query. Behind a
transaction-mode PgBouncer it holds one server connection from its first statement to its
exit. Keep the block to the reads.

### Manual Transaction Control

For complex scenarios where you need explicit commit control:
Expand Down
4 changes: 2 additions & 2 deletions sqlalchemy_foundation_kit/uow/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class AsyncUnitOfWork(Protocol, Generic[T_co]):
Provides three modes of operation:
- ``transaction()``: For write operations with automatic commit/rollback.
- ``managed_session()``: For write operations with **manual** commit/rollback control.
- ``query()``: For read-only operations without transaction management.
- ``query()``: For reads, in one ``READ ONLY`` transaction that is rolled back on exit.
"""

def transaction(
Expand Down Expand Up @@ -95,4 +95,4 @@ def managed_session(
"""

def query(self, isolation_level: str | None = None) -> AbstractAsyncContextManager[T_co]:
"""Create a read-only query context without transaction management."""
"""Create a read-only query context: one ``READ ONLY`` transaction, rolled back on exit."""
46 changes: 37 additions & 9 deletions sqlalchemy_foundation_kit/uow/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from contextlib import asynccontextmanager
from typing import Generic

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from .._typing import T
Expand Down Expand Up @@ -107,6 +108,23 @@ async def apply_isolation_level(
await session.connection(execution_options={"isolation_level": normalized})


async def _begin_read_only(session: AsyncSession) -> None:
"""Make the session's transaction ``READ ONLY``.

The ``postgresql_readonly`` execution option is applied when a connection is checked
out, so on a session that has not checked one out yet it goes with that checkout:
asyncpg then opens the transaction ``READ ONLY`` with no extra round trip, and the
dialect resets the flag when the connection returns to the pool. When ``open_session()``
has already started the transaction -- an isolation level was applied, or an override
ran a statement -- ``SET TRANSACTION READ ONLY`` marks it instead; PostgreSQL accepts
that at any point in a transaction.
"""
if session.in_transaction():
await session.execute(text("SET TRANSACTION READ ONLY"))
else:
await session.connection(execution_options={"postgresql_readonly": True})


@asynccontextmanager
async def _owned_transaction(session: AsyncSession) -> AsyncIterator[None]:
"""Run a block inside a transaction, committing on success and rolling back on failure.
Expand Down Expand Up @@ -227,7 +245,7 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):

Methods:
transaction(): For write operations with automatic commit/rollback.
query(): For read-only operations without transaction management.
query(): For reads, in one READ ONLY transaction that is rolled back on exit.
"""

def __init__(
Expand Down Expand Up @@ -432,11 +450,20 @@ async def query(
self,
isolation_level: IsolationLevel | str | None = None,
) -> AsyncIterator[T]:
"""Create a read-only query context without transaction management.
"""Create a read-only query context: one ``READ ONLY`` transaction, never committed.

The block runs in a single transaction from its first statement until it exits,
when the transaction is rolled back. PostgreSQL enforces ``READ ONLY``: an
``INSERT``, ``UPDATE``, ``DELETE`` or DDL statement, and functions such as
``nextval()``, fail with SQLSTATE 25006. Reads, ``SET LOCAL`` / ``set_config`` and
advisory locks work.

This method is designed for read-only operations and does not start a transaction
or perform any commit/rollback. It's semantically clearer than managed_session()
for read operations.
It is still a transaction, with what that costs: every lock it takes -- the
``AccessShareLock`` on each table read, an advisory xact lock -- is held until the
block exits, ``now()`` is fixed at the first statement, a slow ``await`` inside the
block leaves the connection ``idle in transaction``, and behind a transaction-mode
pooler the block holds one server connection from its first statement to exit.
Keep it short.

Args:
isolation_level: Optional transaction isolation level.
Expand All @@ -451,13 +478,14 @@ async def query(
async with uow.query() as qx:
users = await qx.users.list_all()
user = await qx.users.get_by_id(user_id)
# No commit/rollback - just closes session
# Rolled back on exit -- there is nothing to commit

Note:
While this method is intended for read-only operations, SQLAlchemy does not enforce
this at the session level. It's up to the caller to ensure only read operations are performed.
Only the block's first transaction is read-only. Calling
``session.commit()`` inside ``query()`` ends it, and the session starts an
ordinary one on its next statement. Write in :meth:`transaction` instead.
"""
async with self.open_session(isolation_level) as session:
# No transaction begin/commit - just yield the session
await _begin_read_only(session)
uow = self._transaction_factory(session)
yield uow
Loading
Loading