Skip to main content

taskqueue-toolkit

Broker-agnostic async task queue for Python — RabbitMQ, SQS, SNS, Redis Streams, and Google Pub/Sub behind one TaskQueue[T] interface, plus an optional Postgres-backed outbox pattern.

Why

Publishing/consuming a task shouldn't couple your code to one broker's client library. This package gives you:

  • One interface: publish(task), consume() -> AsyncIterator[QueuedTask[T]], ack()/nack(requeue=...) — nothing broker-specific (no routing keys, topics, partitions, or consumer groups baked into the contract).
  • One connection string: a single DSN's scheme picks the broker; everything else (queue/topic name, region, credentials, emulator endpoint, consumer group) travels as query params on the same string.
  • Generic over your payload: TaskQueue[T] — bring your own task type and an encode/decode pair (T -> bytes / bytes -> T). This package has no opinion on serialization.
  • An optional outbox pattern: write a task to Postgres in the same transaction as the state change that produced it, then let a background relay deliver it to the broker — no dual-write race between your database and your broker.

Install

pip install taskqueue-toolkit[rabbitmq]      # or: redis-streams, aws, pubsub, outbox, all

Usage

from taskqueue_toolkit.queue.factory import create_task_queue

queue = create_task_queue(
    "amqp://guest:guest@localhost:5672/?queue=my.tasks",
    encode=lambda task: task.encode(),
    decode=lambda body: body.decode(),
)

await queue.publish("hello")

async for queued in queue.consume():
    print(queued.task)
    await queued.ack()

DSN schemes

Scheme Broker Example
amqp / amqps RabbitMQ amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=quorum
redis / rediss Redis Streams redis://:password@host:6379/0?stream=my.tasks&group=workers&consumer=worker-1
sqs Amazon SQS sqs://eu-west-3/my.tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
sns Amazon SNS (fan-out) sns://eu-west-3/my-tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
pubsub Google Pub/Sub pubsub://project-id/my-tasks?subscription=my-tasks-subscriber&emulator_host=localhost:8085

Giving up after N attempts

Each QueuedTask reports delivery_count — how many times the broker has delivered that message, counting the current delivery (1 the first time, never 0). A redelivery caused by a crashed consumer or an expired ack deadline counts the same as one caused by an explicit nack(requeue=True), since the broker counts it the same way.

MAX_ATTEMPTS = 3

async for queued in queue.consume():
    if queued.delivery_count > MAX_ATTEMPTS:
        logger.error("giving up on task", extra={"attempts": queued.delivery_count})
        await queued.nack(requeue=False)
        continue
    try:
        await handle(queued.task)
    except Exception:
        await queued.nack(requeue=True)
    else:
        await queued.ack()

The value comes from each broker's own counter, not from this package. Treat it as a lower bound — SQS documents its counter as approximate, and no broker promises exactness across a failover. Two setups can't count at all, and silently report a first delivery forever:

Broker Counts only when
RabbitMQ the queue is a quorum queue (the default here) — classic queues maintain no x-delivery-count
Pub/Sub the subscription has a dead-letter policy — delivery_attempt is unset without one

RabbitMQ queue type

Queues are declared as quorum by default: RabbitMQ's recommendation for task workloads, and the only type that maintains the counter delivery_count reads. A queue's type is fixed when it's created, so pointing this package at a queue that already exists as classic fails with PRECONDITION_FAILED until you either delete and recreate it, or opt out:

amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=classic

Adding a broker this package doesn't ship

The five schemes above are built in and checked for exhaustiveness at type-check time — create_task_queue() can't silently forget one. For a broker outside that list (Kafka, NATS, ...), register a handler once, at import/startup time, instead of forking the package:

from taskqueue_toolkit.queue.registry import register_scheme


def build_kafka_queue(dsn, encode, decode):
    return MyKafkaTaskQueue(dsn=dsn, encode=encode, decode=decode)


register_scheme("kafka", build_kafka_queue)

queue = create_task_queue("kafka://localhost:9092/my-tasks", encode=..., decode=...)

A handler receives the raw DSN string plus the encode/decode pair and returns a TaskQueue[T] — parsing and construction happen together, so there's a single call to keep in sync, not a parser and a constructor spread across two files.

No broker SDK required. TaskQueue[T]/QueuedTask[T] are protocols with no dependencies of their own, and create_task_queue() imports a built-in adapter only when a DSN actually selects it. So a project that brings its own broker client needs none of the extras installed — and asking for a scheme whose SDK is missing names the extra to install rather than raising a bare ImportError:

MissingBrokerExtraError: the 'amqp' DSN scheme needs a broker SDK that isn't
installed — install it with: pip install 'taskqueue-toolkit[rabbitmq]'

Reusing a built-in adapter with a different client. Each adapter types its client against its own narrow protocol (RabbitMQ's Connection/Channel/Deliverable, SQS/SNS's MessageSettler, Redis Streams' EntryAcknowledger) rather than the SDK's own types, so anything structurally compatible can be injected — that's the same seam the in-memory doubles use.

This holds for every adapter: no broker SDK is imported at module level anywhere in the package, so all five adapters (and all five in-memory doubles) import and build on a machine with none of them installed. Each SDK is imported only from that adapter's default client factory, the moment a real connection is opened.

Adapter Inject To replace
RabbitMQ connect=, build_message= aio-pika
SQS client_factory= aioboto3
SNS client_factory= aioboto3
Redis Streams client_factory= redis-py
Pub/Sub publisher_factory=, subscriber_factory= google-cloud-pubsub

For RabbitMQ, override both seams together, since publish() hands the message straight to your exchange:

class MyMessage:
    def __init__(self, body: bytes, persistent: bool): ...


queue = RabbitMqTaskQueue(
    dsn=dsn,
    encode=...,
    decode=...,
    connect=my_connect,  # -> Connection: channel(), close()
    build_message=MyMessage,  # (body, persistent) -> your client's message type
)

connect must return an object satisfying Connection → Channel → Consumable/Publishable → Deliverable; those five protocols are the whole contract, and each lists only the members the adapter actually calls.

Outbox pattern

from taskqueue_toolkit.outbox.repository import OutboxRepository
from taskqueue_toolkit.outbox.relay import OutboxRelay

outbox = OutboxRepository(session=session, encode=encode, decode=decode)

# Inside the same transaction as your domain state change:
await outbox.enqueue(task)
await session.commit()

# In a background worker/process:
relay = OutboxRelay(outbox=outbox, task_queue=queue)
await relay.run_forever()

OutboxRepository/OutboxRelay are generic over the same T as TaskQueue[T]. Create the outbox table (taskqueue_toolkit.outbox.orm.Base.metadata) via your own migration tooling (Alembic, etc.).

Database support: only Postgres is tested and guaranteed — claim_pending()'s SELECT ... FOR UPDATE SKIP LOCKED concurrency guarantee is verified against a real Postgres in this package's own test suite (multiple relay instances racing to claim the same batch). MySQL 8.0+/MariaDB 10.6+ support the same SKIP LOCKED syntax through SQLAlchemy and may well work, but that combination isn't exercised by any test here — treat it as unverified, not supported, until you've validated the concurrent-claim behavior yourself against your actual engine/version. Older MySQL/MariaDB versions don't support SKIP LOCKED at all. SQLite works for single-writer tests (no real concurrent locking) but never for this guarantee under load.

No NoSQL support (yet): the outbox is built directly on SQLAlchemy (DeclarativeBase, select(...).with_for_update(skip_locked=True), AsyncSession) — this isn't a dialect difference to configure around, it's a relational, transactional row-locking primitive that document/key-value/wide-column stores (MongoDB, DynamoDB, Redis, Cassandra, ...) don't expose in an equivalent form. Adding NoSQL support is tracked as a future feature — it would need a separate OutboxRepository implementation per store family, each with its own concurrency mechanism in place of SKIP LOCKED (e.g. MongoDB's atomic findOneAndUpdate, DynamoDB's conditional UpdateItem), and would only preserve the outbox pattern's core guarantee (the task write and the domain state write land in the same transaction) when the outbox table lives in the same transactional store as the domain data it follows from — an outbox in a different store than your domain data reintroduces the dual-write race this pattern exists to avoid.

Testing your own code

taskqueue_toolkit.testing ships an in-memory double for each broker adapter — a real, working implementation of the relevant slice of its SDK (FIFO ordering, ack/nack, consumer groups, fan-out, ...), not a call-recording mock — so you can test code that publishes/consumes tasks without a real RabbitMQ/SQS/SNS/Redis/Pub-Sub:

from taskqueue_toolkit.queue.rabbitmq import RabbitMqTaskQueue
from taskqueue_toolkit.testing import FakeRabbitMqBroker

broker = FakeRabbitMqBroker()
queue = RabbitMqTaskQueue(dsn=dsn, encode=encode, decode=decode, connect=broker.connect)

await queue.publish("hello")
async for queued in queue.consume():
    assert queued.task == "hello"
    await queued.ack()
    break

Each adapter constructor takes an injectable client/connection factory for exactly this (connect= for RabbitMQ, client_factory= for SQS/SNS/Redis Streams, publisher_factory=/subscriber_factory= for Pub/Sub) — defaulting to the real SDK, so passing nothing behaves exactly as before.

Fake Inject into Requires
FakeRabbitMqBroker RabbitMqTaskQueue(connect=broker.connect) nothing extra
FakeSqsBroker SqsTaskQueue(client_factory=broker.client) [aws]
FakeSnsBroker SnsTaskQueue(client_factory=broker.client) [aws]
FakeRedisStreamsBroker RedisStreamsTaskQueue(client_factory=broker.client) [redis-streams]
FakePubsubBroker PubsubTaskQueue(publisher_factory=broker.publisher, subscriber_factory=broker.subscriber) [pubsub]

Create one broker instance per test — each owns its own isolated in-memory state, so there's nothing to reset between tests and no risk of one test's queue leaking into another's.

Security

Decoder[T] runs on bytes received from a broker — in most deployments, a source outside this process's control. Using an unsafe deserializer (pickle.loads, yaml.load without SafeLoader, eval, ...) as your decode callback means whoever can publish to your queue/topic can run arbitrary code in your consumer. Use a safe format (JSON, msgpack, protobuf, ...) unless every publisher is fully trusted.

This package's own code is checked on every push/PR and before every release via bandit (unsafe code patterns) and pip-audit (known CVEs in resolved dependencies), alongside ruff, mypy, and the test suite. Found a vulnerability? Please open an issue.

Development

uv sync --all-extras
uv run pytest
uv run ruff check .
uv run ruff format .
uv run mypy .
uvx bandit -r src/
uvx pip-audit --path .venv

Metadata

Release files for taskqueue-toolkit 0.3.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 taskqueue-toolkit 0.3.0
File Size Uploaded
taskqueue_toolkit-0.3.0.tar.gz 37.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for taskqueue-toolkit 0.3.0
File Interpreter ABI Platform
taskqueue_toolkit-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 90.7 kB

Release files / taskqueue_toolkit-0.3.0.tar.gz

Download URL taskqueue_toolkit-0.3.0.tar.gz
Size 37.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ac6ce0c03bc07fba6e4ba68c686ca34c0b595cf4ae0c9531ffbfcd4f7a5b50a5
BLAKE2b-256 checksum
How to use checksums
550f8a87ddb0bb7451950263ebcb6b3d662f290ad9f465e774529c8906f427ea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 31, 2026.

Transparency log

Release files / taskqueue_toolkit-0.3.0-py3-none-any.whl

Download URL taskqueue_toolkit-0.3.0-py3-none-any.whl
Size 53.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
925ece6dce16691b9faab1065cf67391e1fd361a5f6f92ee98e25683669759e4
BLAKE2b-256 checksum
How to use checksums
340e0bb21854845f26312bff005a41baa49e37f7eff412be89245d655be0cc22
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 31, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

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