Skip to main content

sqlalchemy-inbox

CI License

sqlalchemy-inbox records which messages a consumer has processed, in the same PostgreSQL transaction as the consumer's own writes. A redelivered message finds its marker and is skipped. A failed transaction rolls the marker back, so a retry processes the message again.

It works with SQLAlchemy's asyncio extension on PostgreSQL. It stores no payload and runs no worker. Inbox never begins, commits or rolls back a transaction for you. Only InboxCleaner does that, on its own connections.

pip install sqlalchemy-inbox

You also need an async PostgreSQL driver, such as asyncpg.

The table

Attach the table to your own MetaData and write the Alembic migration yourself. The package owns no migrations.

from sqlalchemy import MetaData
from sqlalchemy_inbox import make_inbox_table

metadata = MetaData()
INBOX_TABLE = make_inbox_table(metadata, table_name="inbox")

The table has three columns: scope and message_id (both varchar(255), together the primary key) and processed_at (timestamptz, defaulting to the database's now()), with an index on processed_at for cleanup.

Registering with modern-di

Inbox takes a session and the table, so it registers like a repository. The session provider must be REQUEST-scoped and cached, so that Inbox and your Transaction get the same session object:

from db_retry import Transaction
from modern_di import Group, Scope, providers
from sqlalchemy_inbox import Inbox


class Database(Group):
    session = providers.Factory(
        scope=Scope.REQUEST,
        creator=create_session,
        cache=providers.CacheSettings(finalizer=close_session),
        kwargs={"engine": database_engine},
    )
    transaction = providers.Factory(scope=Scope.REQUEST, creator=Transaction, kwargs={"session": session})


class Repositories(Group, scope=Scope.REQUEST):
    inbox = providers.Factory(creator=Inbox, kwargs={"session": Database.session, "table": INBOX_TABLE})

If a service wires an uncached session by mistake, Inbox gets a second session that has no open transaction, and claim raises RuntimeError instead of writing a marker that would never commit.

Processing a message

Claim the message inside your transaction, before the business writes:

@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class CreateChatUseCase:
    transaction: Transaction
    inbox: Inbox
    chats_repository: ChatsRepository

    @postgres_retry
    async def __call__(self, *, command: CreateChat) -> None:
        async with self.transaction:
            if not await self.inbox.claim(scope="chats.create-chat.v1", message_id=command.idempotency_id):
                return
            await self.chats_repository.create(command)
            await self.transaction.commit()

claim returns True when this transaction inserted the marker, and False when the message is already committed. If another transaction holds an uncommitted marker for the same key, claim waits for it to finish. Retrying the whole block with postgres_retry is safe: if an earlier attempt committed, the claim on the retry returns False.

claim does not accept None. If a message can arrive without an id, decide in the handler what to do with it: reject it, or skip the inbox in a branch you can see. The package does not hash payloads or fall back to Kafka offsets.

Cleaning up

Markers are kept until you delete them. Run InboxCleaner from a cron job:

import asyncio
import datetime

from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy_inbox import InboxCleaner


async def main() -> None:
    engine = create_async_engine(DATABASE_DSN)
    try:
        cleaner = InboxCleaner(engine=engine, table=INBOX_TABLE, retention=datetime.timedelta(days=7))
        await cleaner.cleanup()
    finally:
        await engine.dispose()


asyncio.run(main())

A cleanup run takes one cutoff from the database clock, now() minus retention, and deletes the markers older than it in batches of batch_size (1000 by default), one transaction per batch, until a batch deletes nothing. cleanup() returns a CleanupResult with the cutoff and the deleted_count. It logs each batch at DEBUG and the total at INFO to the sqlalchemy_inbox logger.

Batches that committed stay deleted if a later batch fails or the task is cancelled, and the error reaches your cron job. The cleaner doesn't retry: the next run deletes what is left. It skips markers that another transaction holds, so it never waits on a consumer, and two runs at once share the work. Each batch runs at READ COMMITTED whatever the engine's default. The engine stays yours to dispose.

Guarantees

What the package guarantees, given a PostgreSQL transaction that you own:

  • The marker and your writes commit together or not at all. That includes a failure in your code, a database error, a cancelled task and a failed COMMIT.
  • Two transactions processing the same (scope, message_id) at once commit the database effects at most once. The second waits for the first and returns False if the first committed, True if it rolled back.
  • A redelivery after a successful commit returns False, including when the process died after the commit but before the broker was acknowledged.
  • If you can't tell whether a commit landed, run the transaction again: claim returns False if it did and True if it didn't.

What it does not guarantee:

  • Exactly-once delivery from end to end. The guarantee covers database effects for one key while its marker is retained.
  • Effects outside PostgreSQL. An HTTP call made in a transaction that later rolls back has already happened, and the retry makes it again. Send those through a transactional outbox, or make the receiving side idempotent.
  • Anything after the marker is deleted. Once cleanup removes it, the same key counts as a new message.
  • Under REPEATABLE READ or SERIALIZABLE, a conflicting claim raises a serialization failure instead of returning False. db-retry's postgres_retry retries it, and the retry returns False.

Choosing a scope

A scope names one logical consumer of a message. Write it as a constant, such as "chats.create-chat.v1".

Don't build it from a function name, module path, consumer group or topic. Those change when code is refactored or a deployment is renamed, and a changed scope makes every retained message look new.

Two consumers that each need to process the same message use different scopes. Change the version suffix only when you want old messages to be processed again.

Choosing a retention

Retention must cover the longest time a message can come back. That includes:

  • the Kafka topic's retention, which defaults to 7 days,
  • DLQ retention plus the time it takes to replay from it,
  • manual replays and backfills.

A day of retention is enough only if nothing can be redelivered after a day. Each marker is dated by the start of the transaction that wrote it, so a marker from a long transaction expires that much earlier. At retention measured in days, that makes no practical difference.

Kafka offsets for failed handlers

The inbox makes redelivery safe. It cannot cause a redelivery. With faststream-concurrent-aiokafka, a handler that raises still has its offset committed. Only a cancelled handler holds the offset back. So when your transaction fails, the marker rolls back as it should, but Kafka will not send the message again.

If a failed message has to be processed later, retry inside the handler (for example with postgres_retry) or send it to a dead-letter queue before the handler returns. The same applies to errors db-retry deliberately doesn't retry, such as StatementCompletionUnknownError, where you can't tell whether the commit landed.

Metadata

Release files for sqlalchemy-inbox 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sqlalchemy-inbox 0.2.0
File Size Uploaded Provenance Actions
sqlalchemy_inbox-0.2.0.tar.gz 8.4 kB Without provenance Details for sqlalchemy_inbox-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for sqlalchemy-inbox 0.2.0
File Interpreter ABI Platform Actions
sqlalchemy_inbox-0.2.0-py3-none-any.whl Python 3 none any Details for sqlalchemy_inbox-0.2.0-py3-none-any.whl

Total release size: 17.2 kB

Release files / sqlalchemy_inbox-0.2.0.tar.gz

Download URL sqlalchemy_inbox-0.2.0.tar.gz
Size 8.4 kB
Tags Source
SHA-256 checksum
How to use checksums
7b7926315b2ac0d5350e2ca35447906ae5da16cb4677109efe305e395b7e72da
BLAKE2b-256 checksum
How to use checksums
40f770733aca9edd23f3790466c3da3ab0f9461874556e029be17654c56978b4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / sqlalchemy_inbox-0.2.0-py3-none-any.whl

Download URL sqlalchemy_inbox-0.2.0-py3-none-any.whl
Size 8.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4ccd57e0da609c836e3fa5e18d75d7be98a18f369d12e829abafe494e0d50100
BLAKE2b-256 checksum
How to use checksums
8b6b95420bcc8cb7632b535bf989da608c4c5313d84a35c94ad550f416f9018b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for sqlalchemy-inbox 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page