Integrations¶
Database-side primitives for message-driven consumers (§28) and micro-batching (§17.1). dbkit is scoped as database-only and does not ship a broker-facing adapter — an application wires these into its own consumer loop.
inbox_ddl ¶
DDL for the inbox table (§28.3). Time-partition it in production for cheap pruning —
see :func:partitioned_inbox_ddl/:func:inbox_month_partition_ddl for that variant.
partitioned_inbox_ddl ¶
DDL for a partitioned inbox table, range-partitioned by processed_at (§28.3).
A single unbounded consumed_messages table's dedup lookup gets slower and its storage
grows without bound as message volume accumulates. Range-partitioning by month makes
pruning old data a cheap DROP TABLE on a partition instead of a slow DELETE. The
partition key must be part of the primary key for declarative partitioning, so this variant
keys on (consumer_name, message_id, processed_at) instead of just the first two.
Partitions themselves aren't created automatically — call
:func:inbox_month_partition_ddl for each month you need (typically from a scheduled job
that creates the next 1-2 months ahead of time; pg_partman or a cron-triggered dbkit
script both work). Older partitions can simply be dropped once no longer needed for
dedup/audit purposes — a message's redelivery window is bounded by the broker's own
retention, so a partition older than that window is always safe to drop.
inbox_month_partition_ddl ¶
DDL for one calendar-month partition of :func:partitioned_inbox_ddl's table.
year/month are passed explicitly (not computed from the current date) so this stays
a pure function — call it with whatever month you actually need created, e.g. from a
scheduled job that creates the next month a few days before it starts.
process_once
async
¶
process_once(
db: Any,
*,
consumer: str,
message_id: str,
target: DatabaseTarget,
inbox_table: str = DEFAULT_INBOX_TABLE,
) -> AsyncIterator[tuple[Any, bool]]
Open a transaction, claim the message in the inbox, and yield (tx, first_time).
Do the business writes on tx only when first_time is True; a duplicate delivery
still commits (a harmless no-op) so the broker message can be acked::
async with process_once(db, consumer="c", message_id=mid, target=t) as (tx, first):
if first:
await tx.execute(INSERT_ORDER, params)
ack_after_commit
async
¶
ack_after_commit(
db: Any,
*,
consumer: str,
message_id: str,
target: DatabaseTarget,
work: Callable[[Any], Awaitable[None]],
ack: Callable[[], Awaitable[None]],
dead_letter: Callable[[DatabaseError], Awaitable[None]]
| None = None,
retry: Callable[[DatabaseError], Awaitable[None]]
| None = None,
inbox_table: str = DEFAULT_INBOX_TABLE,
) -> bool
Run work idempotently, then ack — the full §28 consume flow.
Returns True if the message was processed (or was a duplicate) and acked. Behavior on failure:
- commit-unknown -> re-raised; the message is not acked (idempotent replay will dedupe).
- retryable error ->
retrycallback if given, else re-raised (nack/requeue). - permanent error ->
dead_lettercallback if given, else re-raised (route to DLQ).
Transactional outbox (§28.4)¶
The mirror of the inbox: enqueue an event in the same transaction as the business write so the event exists iff the change persisted, then relay unsent rows to a broker at-least-once. Single-shard (the outbox table lives on the same database as the write); pair with the inbox on the consumer for effectively-once. Schema-agnostic — you pass the table name and an opaque JSON payload.
from dbkit.integrations import outbox_ddl, enqueue, drain
await db.execute(sql(outbox_ddl()), target=t) # once, at setup (script: split on ';')
async with db.transaction(target=t) as tx: # atomic: write + event
await tx.execute(UPDATE_ORDER, params)
await enqueue(tx, topic="order.completed", payload={"order_id": oid})
# relay loop (separate worker): publish unsent rows, mark them sent
await drain(db, target=t, publish=my_async_publish)
outbox_ddl ¶
DDL for the outbox table (§28.4).
Columns: id (monotonic bigserial — relay order + cursor), topic (routing key),
payload (opaque jsonb event body), created_at, and sent_at (NULL until the
relay publishes it). A partial index on the unsent rows keeps the relay's "next batch" scan
cheap regardless of how many already-sent rows remain. Time-partition it in production for cheap
pruning of sent rows — see :func:partitioned_outbox_ddl/:func:outbox_month_partition_ddl.
partitioned_outbox_ddl ¶
DDL for a partitioned outbox table, range-partitioned by created_at (§28.4).
An unbounded outbox grows without limit as events accumulate; range-partitioning by month makes
pruning old (already-sent) data a cheap DROP TABLE on a partition instead of a slow
DELETE. The partition key must be part of the primary key for declarative partitioning, so
this variant keys on (id, created_at). Create each month's partition with
:func:outbox_month_partition_ddl (typically from a scheduled job a month ahead); drop
partitions once every row in them has been sent and is past any audit window.
outbox_month_partition_ddl ¶
DDL for one calendar-month partition of :func:partitioned_outbox_ddl's table.
year/month are passed explicitly (not computed from the current date) so this stays a
pure function — call it with whatever month you need created, e.g. from a scheduled job that
creates the next month a few days before it starts.
enqueue
async
¶
enqueue(
tx: Any,
*,
topic: str,
payload: Mapping[str, Any],
table: str = DEFAULT_OUTBOX_TABLE,
) -> int
Insert one event into the outbox on the caller's transaction tx and return its id.
Call this inside a db.transaction(...) block alongside the business writes so the event and
the change commit atomically::
async with db.transaction(target=t) as tx:
await tx.execute(UPDATE_ORDER, params)
await enqueue(tx, topic="order.completed", payload={"order_id": oid})
payload is serialized to jsonb and bound as a parameter (never interpolated).
drain
async
¶
drain(
db: Any,
*,
target: DatabaseTarget,
publish: Callable[[Mapping[str, Any]], Awaitable[None]],
batch_size: int = 100,
table: str = DEFAULT_OUTBOX_TABLE,
) -> int
Relay a batch of unsent outbox rows to publish, then mark them sent. Returns the count.
Claims up to batch_size unsent rows in id order with FOR UPDATE SKIP LOCKED — so many
relay workers can run concurrently without blocking or double-claiming — publishes each via the
publish callback (receives a mapping with id/topic/payload/created_at), then
stamps sent_at for the whole batch and commits. Everything runs in one transaction: if
publish raises, the claim rolls back and the rows are retried on the next :func:drain.
Delivery is at-least-once: a crash after the broker accepts a message but before commit
replays it. Make publish idempotent or dedupe on the consumer
(:mod:dbkit.integrations.inbox). Loop :func:drain from a relay worker (a return of 0 means
the outbox is momentarily empty).
BatchCollector ¶
BatchCollector(
flush: Callable[[Sequence[T]], Awaitable[None]],
*,
max_size: int = 1000,
max_delay_ms: float = 50.0,
)
Bases: Generic[T]
Aggregates items across concurrent producers and flushes them as one batch (§17.1).
flush is called with the buffered batch whenever max_size/max_delay_ms
is reached; it should perform the batched write (e.g. db.insert_many).
HTTP APIs (FastAPI / Starlette)¶
Category-driven RFC 7807 responses for unhandled DatabaseErrors: overload
(pool timeout, limiter, circuit open, backend unreachable) → 503 with a
Retry-After header (derived from the circuit breaker's open_seconds when you
pass your facade); query timeouts → 504; programming/integrity errors stay
500 and loud. Requires the dbkit-sql[fastapi] extra (Starlette only).
from dbkit.integrations.fastapi import install_exception_handlers
install_exception_handlers(app, database=db)
install_exception_handlers ¶
install_exception_handlers(
app: Starlette | Any,
*,
retry_after_seconds: int | None = None,
database: Any = None,
expose_detail: bool = False,
) -> None
Register a :class:DatabaseError handler on app (FastAPI or Starlette).
retry_after_seconds populates the 503 Retry-After header. When omitted
and database (an AsyncDatabase/Database) is given, it derives from
the circuit breaker's open_seconds — the earliest moment a retry can find
the breaker half-open. Falls back to 2 seconds.
expose_detail includes the sanitized error message in the response body —
keep it off in production unless the API is internal.
Per-query session settings¶
Query.settings applies transaction-local PostgreSQL settings right before the
statement — parameterized set_config(name, value, true), names validated:
COUNTS = Query(
name="inbox.counts",
statement=sql("SELECT ..."),
settings={"jit": "off"}, # or {"work_mem": "64MB"} for a heavy aggregate
)
The setting never leaks: it resets when the statement's transaction ends, before the connection returns to the pool.