Units of Work and db()¶
A unit is one Unit of Work: one transaction on one database. It commits
when its body succeeds and rolls back on any exception, HTTPException
included. Your code never commits: it reads and writes through db(), the
session of the running unit, and the framework decides commit or rollback at
the unit's end. Everything the unit queued (events, audit rows, post-commit
callbacks) commits with it or not at all.
What you get by default, and what you declare¶
| You write | Sandloom does for you |
|---|---|
db().add(row), await db().flush() in a service |
the request's unit: created at the first db(), committed before the response on success, rolled back on error |
| nothing | one transaction per request; the commit runs before_commit checks, COMMIT, outbox dispatch and on_commit callbacks, in that order |
await E(...).publish() |
an outbox row in the same transaction, dispatched only after the commit |
record_audit(...) |
an audit row in the same transaction |
await on_commit(callback) |
the callback runs only after the commit, never after a rollback |
async with open_uow() as session: |
a unit of your own: in a task, a script, a child task or an operation-intent route |
async with db().begin_nested(): |
a savepoint; raising inside it rolls back to it and drops the effects queued inside it |
Using db()¶
from sandloom.sdk import db
async def create(body: NoteIn, *, author_id: UUID) -> Note:
note = Note(author_id=author_id, **body.model_dump()) # tenant_id from the bound tenant
db().add(note)
await db().flush() # materialise ids and constraints; never commit()
return note
- Never call
commit()orrollback()on a session in module code (SANDLOOM-C006). Useflush()to get ids and constraint errors early. db()is synchronous. Outside any unit it raisesUnitOfWorkRequiredError(SANDLOOM-R002).- In a request the first
db()creates the request unit lazily: no connection is checked out until the first statement. A read route that never callsdb()uses no connection. Routes may still take the session as a parameter (DbSession, advanced, fromsandloom.sdk.web); the unit is then opened eagerly, and on a write route the durable idempotency claim runs before the handler. - In a contract method
db()is the provider unit's session, on the contract pool with its tenant hook (Contracts). - In a consumer
db()is the delivery's unit; a delivery that raises rolls back and is redelivered (Events). - In a task or script open a unit with
open_uow(); inside the blockdb()returns its session (Background tasks).
open_uow(database=None) opens a unit on the module's primary database, or
on a named one. It replaces today's ad-hoc sessions and is the only way to
open a unit outside a request, a consumer or a contract.
Ownership: one task per unit¶
A unit belongs to one asyncio task. In a request the owner is the handler task, the task that runs the dependencies and the endpoint.
db()from another task is refused withConcurrentSessionUseError(SANDLOOM-R013). Child tasks (asyncio.create_task,asyncio.gather) inherit the context, so the common mistake is caught at the child's firstdb(). A child that callsdb()before the parent did creates nothing: the parent's laterdb()creates the request unit and owns it.- A child task that needs the database opens its own unit:
from sandloom.sdk.data import open_uow # sandloom: advanced child task needs its own unit
async def refresh_one(item_id: UUID) -> None:
async with open_uow():
item = await db().get(Item, item_id) # db() is this unit's session
...
await asyncio.gather(*(refresh_one(i) for i in ids))
Write admission still applies: the first unit of the request that writes
becomes its writer, so a child may be the only writer under a read-only
parent, and is refused when another unit already wrote
(Write admission).
- Passing a session object to another task is unsupported. The framework
detects it on a best-effort basis (session methods, the cursor), but some
forms cannot be detected: in-place mutation of a mutable column value (for
example obj.data["k"] = v on a JSON column without MutableDict),
attribute assignment on a loaded instance, and direct use of the session's
internals. Do not rely on detection; open a unit per task.
- After a unit ends its session is closed and marked. Any later use of a
captured reference raises SANDLOOM-R002
instead of silently starting a new transaction.
Commit, rollback, cancellation¶
- The commit runs, in order:
before_commitchecks, the commit-time session re-check when it is armed (Route policy),COMMIT, outbox dispatch,on_commitcallbacks. - The request unit commits before the response is sent: a client never
acts on a
200whose write is not yet visible. - Cancellation is an exception: the unit rolls back. Cancellation during
COMMITis an ambiguous commit, resolved by the dedupe records of the effect model (ADR-0005). - Savepoints.
db().begin_nested()uses the same session. A rolled-back savepoint discards the effects registered inside it (events, audit rows, callbacks). After a call that may roll back a savepoint, readentity.idbefore the call: a rollback expires ORM instances on async sessions.
Events and audit go through the unit¶
await E(...).publish() writes the event to the outbox_messages table in
the same transaction. After the commit a dispatcher pushes it to the bus; the
relay retries any dispatch that failed. So an event is published if and only
if the change that produced it commits.
- The endpoint's unit inserts the business rows and the outbox row, in one transaction, and commits.
- The outbox relay picks up the committed row and dispatches its envelope to the event bus.
-
The bus acknowledges it. Without an acknowledgement, the relay retries.
-
The publish preflights the channel, the schema and the payload size inside the transaction, so an event the bus would reject is never committed.
- Outside a unit, publishing raises
UnitOfWorkRequiredError. A deliberately lossy publish passesrequired=Falseto the L2publish_eventand needs a marker on the call:# sandloom: best-effort <reason>(SANDLOOM-C007). record_auditfollows the same rule (Audit).- The relay. A failed dispatch is retried with exponential backoff
(
OUTBOX_RELAY_BACKOFF_BASE_SECONDS, default 2, doubled per attempt, capped atOUTBOX_RELAY_BACKOFF_MAX_SECONDS, default 300). When the broker itself is down the relay opens a circuit and cools down (OUTBOX_RELAY_CIRCUIT_BASE_SECONDS/_MAX_SECONDS, defaults 1 / 30). A row that exhaustsOUTBOX_RELAY_MAX_ATTEMPTS(default 10) is dead-lettered (dead_atset,outbox_dead_totalincremented); return it withsandloom outbox list-deadandsandloom outbox requeue. The envelope id is unchanged, so consumers that already applied it dedupe it.OUTBOX_RELAY_ENABLED=autoruns the relay in production processes only (one elected worker per container under gunicorn); turning it off in a production app process needs the accepted riskrelay_disabled.
Effects that wait for the commit, checks that run before it¶
await on_commit(callback)(sync or async, no arguments) runs right after the unit commits. It never runs after a rollback, nor when it was registered inside a savepoint that rolled back. Outside any unit it runs at once. A failing callback is logged and never fails the committed request. Use it for effects outside the database: a cache write, a notification. Revoking a user's tokens has its own helper,revoke_user_tokens_on_commit(sandloom.sdk.security).await before_commit(callback)(sandloom.sdk.effects) runs after the handler, right beforeCOMMIT; raising rolls the unit back. Use it to re-validate an input owned by another service. It narrows a race window to "check, then COMMIT"; it does not close it.
One database per invariant¶
Each database has its own unit and transaction; there is no distributed transaction. One business invariant belongs in one database. A route writes to one database, its write store; a second writing unit in the same request is refused at its first write (Write admission). When two databases must stay consistent, use events or a saga, never a shared transaction.
A write that must survive the request's rollback (rare) needs its own transaction opened after the request, never nested inside it. A route whose job is a sequence of short units around an external call (billing checkout) uses the operation-intent mode (Lifecycle).
What breaks it¶
| Code | What it catches |
|---|---|
| SANDLOOM-C006 | commit() / rollback() in module code |
| SANDLOOM-C007 | a best-effort publish or audit without a marker |
| SANDLOOM-C020 | a hand-written session dependency without scope="function" |
| SANDLOOM-R002 | db() or an effect outside any unit; a closed unit reused |
| SANDLOOM-R013 | db() from a task that does not own the unit |
| SANDLOOM-R007 | a second writing unit in one request |
| SANDLOOM-R008 | nested or overlapping units where they are not allowed |
Guarantees, precisely
- Commit on success, rollback on any exception, before the response;
the commit pipeline order
before_commit→ COMMIT → outbox dispatch →on_commit(G-UOW-01, G-UOW-02, G-UOW-04). - A rolled-back savepoint drops the effects registered inside it (G-UOW-05).
db()is the innermost open unit's session; outside any unit SANDLOOM-R002; the lazy request unit is created only by the handler task; a closed unit refuses reuse (G-UNIT-01).db()from a non-owner task is SANDLOOM-R013 (C-UNIT-01). Detection of a session passed to another task is best-effort, not a guarantee; the undetectable forms above are pinned by tests (C-UNIT-02).- The outbox gives no loss between the commit and the bus; delivery is at-least-once (G-OUTBOX-*). The full effect matrix: ADR-0005; the labels: Guarantees and labels.
The ledger with the enforcing tests: Guarantees & capabilities.
Kinds of unit
request unit (db() / DbSession in a request), named request unit
(Database("x")), block unit (open_uow()), consumer unit, contract unit
(a command unit, or a read unit / the request's shared read-scope
connection), planned read, task unit, saga-step unit, and the kernel's own
units (outbox marking, post-commit dispatch, guard bookkeeping). Each
route kind and each non-route unit has one row in the
lifecycle matrix.
Design rationale¶
- ADR-0005: the effect model
- ADR-0010: Public API 1.0, "Transaction and replay lifecycle"