ATasks
ATasks is an asynchronous distributed task queue system.
Every task is defined as an asynchronous coroutine. We call such a task atask:
a(synchronous) task.
atask looks like a usual asynchronous coroutine. It may be awaited using
await syntax, and controlled by the asyncio package.
The atask may await other coroutines and atasks. Because of asynchronous
nature of atask it doesn't block a thread evaluating atask and
allows easy and transparent task decomposition as usual asynchronous
procedure, including sequential and parallel awaiting of other atasks.
Installation
Stable version from the PyPi package repository
pip install atasks
Last development version from the GitHub source version control system
pip install git+git://github.com/nnseva/atasks.git
Initializiation
Before execution some number of core objects should be constructed and initialized.
from atasks.transport.backends.amqp import AMQPTransport
from atasks.router import get_router
from atasks.codecs import PickleCodec
...
PickleCodec()
transport = AMQPTransport()
await transport.connect()
if mode == 'server':
router = get_router()
await router.activate(transport)
Codec
Codec determines a way to encode and decode objects passed through the network. It should support as many types as it can.
The atasks.codecs.PickleCodec provided by the package uses standard python pickle package.
It is universal but not always safe solution.
from atasks.codecs import PickleCodec
...
PickleCodec()
User can inherit atasks.codecs.Codec as a base class and create an own codec implementation.
Just replace all methods generating NotImplementedError. Note that most of methods are asynchronous.
from atasks.codecs import Codec
class MyCodec(Codec):
async def encode(self, obj):
...
async def decode(self, content):
...
To activate a codec, yu need just create an instance of it. The codec is installed into the system while construction.
Transport
Transport determines the method of sending requests and returning results
from awaiter to the performing coroutine and back to support awaiting
atasks among a network.
The atasks.transport.base.LoopbackTransport provided by the package passes
all requests back to the awaiter thread only. It doesn't allow atasks
performing distribution among several processes or even threads. You can
use it for the testing purposes.
The atasks.transport.backends.amqp.AMQPTransport provided by the package passes
requests through the RabbitMQ or other AMQP broker to any ATasks worker started
on the same or another host.
After creation a transport instance, the asynchronous connect() method of just
created instance should be awaited.
from atasks.transport/base import LoopbackTransport
from atasks.transport.backends.amqp import AMQPTransport
...
if transport == 'loopback':
LoopbackTransport()
elif transport == 'amqp':
AMQPTransport()
await transport.connect()
Other transport kinds may be implemented later.
AMQPTransport is built entirely on aio_pika (never on pika or another
AMQP client) - the whole project is expected to standardize on this one
AMQP client library, so aio_pika itself should never need to be imported
directly from application code. Notable constructor options:
url- the AMQP broker URL (defaultamqp://localhost/).reconnect_interval- seconds between reconnection attempts after the broker connection is lost (default5), passed straight through toaio_pika.connect_robust.client_properties- optional dict merged into the AMQP connection handshake (e.g.{'connection_name': 'my-service'}), useful for identifying connections in the broker's management UI/API.prefix- routing-key/queue/exchange namespacing prefix (default'atask') - give distinct services/environments distinct prefixes to keep their RPC queues, task-queues, and broadcast exchanges from colliding.
See "Request timeout and combining @atask with backoff" below for how
AMQPTransport surfaces RPC timeouts and connection loss to the caller.
User can inherit atasks.transport.base.Transport as a base class and create an own
transport implementation. Just replace all methods generating NotImplementedError. Note that
most of methods are asynchronous.
from atasks.transport.base import Transport
class MyTransport(Transport):
async def connect(self):
...
async def disconnect(self):
...
async def send_request(self, name, content):
...
Router
Router determines a way how the reference looks like, how it is awaited, what data are passed over the network etc. Router is a core of the ATasks package.
The atasks.router.Router is an only default router implementation.
User can inherit atasks.router.Router and create an own
router implementation if necessary.
As a rule, you don't need to do it. In this case, you can just
use get_router() function to get a default router instance.
from atasks.router import get_router
...
router = get_router()
Client and Server
If your application should send requests only, no any other actions required on the initialization stage.
Server application which listens to events should also activate a transport to receive requests:
server = AMQPTransport()
...
router = get_router()
await router.activate(server)
Markup an asynchronous distributed task
Decorator atasks.tasks.atask is used to markup the asynchronous coroutine
(or even synchronous returning future object) as an asynchronous distributed
task.
Note that the first call to the wrapper creates a default router. You should create your own Router (or ancestor) instance before the first call to the wrapper if necessary.
@atask
async def some_task(a):
...
Client and server should use the same module defining atasks as a rule.
In order to await atask the atask name is used. Default name is determined
by the coroutine name and containing module. You can replace a default name
using additional name parameter of the decorator:
@atask(name="some_other_name")
async def some_task(a):
...
Both bare (@atask) and parameterized (@atask(...)) forms work, and so do
the equivalent forms of @atask_queue and @atask_broadcast described below.
Awaiting evaluation of the asynchronous distributed task
The atask is awaited as a usual coroutine. You can use await keyword, or
get a future calling atask synchronously and control future using asyncio module.
@atask
async def some_task(a):
ret = await some_other_task(a)
@atask
async def some_other_task(a):
...
async def not_a_task_just_coro():
a = await some_task(42)
...
Request timeout and combining @atask with backoff
@atask accepts an optional timeout (seconds). If the worker doesn't reply
in time, the caller gets atasks.transport.base.RequestTimeoutError instead
of waiting forever - see "When the worker evaluating atask is crashed"
above for the full story, including connection-loss handling.
Because @atask and backoff.on_exception(...) are both just async-function
decorators, they compose in either order for either purpose. The recommended
shape for a function that runs remotely applies independent retry policies on
each side of the wire:
import backoff
from atasks.tasks import atask
from atasks.transport.base import ConnectionLostError, RequestTimeoutError
@backoff.on_exception(backoff.expo, (RequestTimeoutError, ConnectionLostError)) # retry the whole remote call - caller side
@atask(timeout=30)
@backoff.on_exception(backoff.expo, SomeTransientLocalError) # retry the local execution - worker side
async def some_processing_function(...):
...
return result
- The worker-side
backoff.on_exceptionretries the underlying function locally before ever reporting failure back to the caller - transient problems (a flaky downstream HTTP call, a momentary DB hiccup) never even cross the wire. - The caller-side
backoff.on_exceptionretries the entire remote call - including a freshcorrelation_idand reply-to round trip - when the worker-side retries were exhausted, when the worker crashed outright (ConnectionLostErrorwhile a request was in flight, or the sameRequestTimeoutErroras a plain timeout, since - as noted above - a crashed worker and a slow worker look the same from here).
Decorator order matters: @atask must sit directly on the function that
should be registered as (and invoked as) the remote task; a worker-side
backoff.on_exception goes below it (applied to the plain local coroutine
first), while a caller-side backoff.on_exception goes above it (applied
to the network-calling stub @atask produces).
Task-queue (fire-and-forget, competing consumers)
Use @atask_queue when the caller doesn't need (or want to wait for) a
result, and exactly one instance among however many are currently listening
should handle each call - the classic AMQP work-queue pattern. Good fit for
specialized single-purpose consumer services, e.g. recalculating a rating
when a contract closes, or generating a notification from a tracking event.
from atasks.tasks import atask_queue
@atask_queue
async def recalculate_rating(contract_id):
...
On the calling side, await recalculate_rating(contract_id) publishes the
event and returns None immediately - it does not wait for, or receive, any
result.
On the consuming side, a process registers itself as one of the (possibly
several) competing consumers explicitly, since - unlike the RPC pattern's
single router.activate(transport) - there can be more than one independent
task-queue (and/or broadcast topic, see below) active in the same process:
from atasks.router import get_router
router = get_router()
await router.activate_queue('mypackage.recalculate_rating', transport)
Every instance which calls activate_queue with the same name binds to the
same durable, named queue - so they compete, and every published event is
delivered to exactly one of them, never to more than one, and never lost even
if published before any consumer has started (the queue is declared durably
by the publisher too).
Broadcast/subscribe (fire-and-forget, fan-out)
Use @atask_broadcast when every currently-subscribed instance should
receive and process its own independent copy of each event - the opposite of
@atask_queue's competing-consumers semantics. This is the pattern a fleet
of WebSocket-gateway-style processes needs: every instance holds a different
set of live client connections, and only that instance knows which of them
are relevant to a given event, so every instance must see every event.
from atasks.tasks import atask_broadcast
@atask_broadcast
async def relay_realtime_event(payload):
...
from atasks.router import get_router
router = get_router()
await router.activate_broadcast('mypackage.relay_realtime_event', transport)
Topology: one shared (fanout) exchange per broadcast name, with one
exclusive, auto-delete queue per subscribing instance bound to it - the same
approach used by channels_rabbitmq. Each instance gets its own full copy of
the stream while it's connected. Two direct consequences of the exclusive
auto-delete queue:
- a subscriber only receives events published while it is actively
subscribed - there is no replay of history from before it joined (unlike
the durable queue used by
@atask_queue, which retains unconsumed events); - this topology has a known, accepted-for-MVP scaling limitation: every subscribed instance receives every published event regardless of whether it is relevant to any connection that instance actually holds, so broker-side + deserialization + filtering load grows linearly with the number of subscribed instances, independent of real per-event audience size. Sharding by routing key/topic, a connection-presence registry for addressed delivery, or broker-side filtering are the directions to revisit this if it becomes a bottleneck - not something this package solves today.
Delivery guarantees and idempotency - at-most-once, never exactly-once
All three patterns - @atask (RPC), @atask_queue (task-queue), and
@atask_broadcast (broadcast) - are at-most-once at the message-delivery
level, not at-least-once. Every message is acknowledged to the broker as
soon as it is received, before the registered handler ever runs - an
architectural constraint, not an oversight: see the comment above
_on_message in atasks/transport/backends/amqp.py for why deferring the
ack until the handler finishes isn't safe here (a single transport's RPC
consumer shares one AMQP prefetch slot across every task name it serves, and
delaying the ack that long deadlocks on any nested/self-referential call
chain - one task's handler calling another task the same worker also
serves). The practical consequence: if the process handling a message
crashes, is killed, or loses its connection while the handler is still
running, that message is gone. AMQP will not redeliver it to another
consumer, and nothing else will ever be told the work didn't happen.
- For RPC (
@atask), this loss is at least observable from the caller's side:send_requestis still waiting on a reply that will now never arrive, so it surfaces asRequestTimeoutError(orConnectionLostError, if the connection itself drops - see "When the worker evaluatingataskis crashed" below). If the call site follows the documented caller-sidebackoff.on_exceptionpattern, that retry re-issues a brand-new request - which can end up running the underlying function twice (if the crashed worker had actually finished the work moments before dying, just never got to reply) rather than exactly once. This retry is an application-level convention this package documents and expects you to add - not something AMQP or this library provides automatically. - For
@atask_queue/@atask_broadcast, there is no caller waiting for anything to compare against:publish_event/publish_broadcastreturn as soon as the message is handed to the broker, with no confirmation that it was ever processed. If the consumer that picked it up then crashes mid-handler, the work is silently dropped - no retry, no error, no log anywhere pointing at it. Anything that must survive a crash mid-processing has to be built on top of these two patterns (the handler durably recording its own progress/results before returning, an application-level dead-letter queue, external monitoring, etc.) - it does not come for free.
This package deliberately does not attempt to solve either problem for
you. For RPC, de-duplication (idempotency keys, "processed event" tables,
INSERT ... ON CONFLICT DO NOTHING-style upserts, etc.) is the
caller's/handler's responsibility whenever a caller-side retry is in play -
every function registered with @atask should be safe to run more than once
for the same logical input. For @atask_queue/@atask_broadcast, surviving
a crash mid-processing is the handler's own responsibility to design for, if
the use case needs it at all - the delivery mechanism itself won't help.
Namespaces
Objects may be instantiated in separate namespaces. Just
pass an additional namespace=... parameter to:
- constructor of codec, transport, or route object
- atask decorator
get_route,get_transport, orget_codecfunction
One namespace is completely separated from anoher. Every namespace uses it's own set of router, transport, and codec, so init them separately for every namespace which is used in your application.
The default namespace has a name default.
You can await task from one namespace in another.
@atask(namespace='one')
async def some_task():
await some_other_task()
...
@atask(namespace='other')
async def some_other_task():
....
Commands
The package provides a command-line interface through the atasks.run module.
Run one or more files or Python modules containing @atask definitions and an
optional asynchronous aiomain coroutine:
python -m atasks.run file-or-module [file-or-module ...] [options]
For example, run the included scenario in loopback mode:
python -m atasks.run dev.tests.scenarios --mode loopback --verbosity 3
Each referenced file or module is loaded. If it defines aiomain, that
coroutine is evaluated; parsed command-line options are passed to it as keyword
arguments.
The runner initializes the codec and transport, then runs in one of three
modes: server, client, or loopback. The loopback mode allows the same
process instance to act as both server and client.
Available options are:
-M,--mode- execution mode:client(default),server, orloopback.-T,--transport- transport:loopback(default) oramqp.-U,--url- URL of the AMQP transport.-v,--verbosity- logging verbosity from0to4(default1).-L,--loggers- logger names to configure; defaults toatasks.-o,--option- additional values made available toaiomainasopt.
Run the module with --help to see the complete command-line reference:
python -m atasks.run --help
Note that if you use dedicated server process instance, you should not use
loopback transport (which is not appropriate to reach the dedicated server
in this case). Use amqp (or other interprocess transport) instead.
The module naming is different slightly depending on what you use in command line,
either file name, or module name. Use the same module naming starting server
and client to avoid misnaming of atasks.
You can start several modules simultaneously in one process instance enlisting them all in the command line.
You can start several server process instances, the client will then request them in arbitrary order.
See dev/tests/scenarios.py file as an example of the file which can be called
by the atasks.run module.
Inspiration
The idea of ATasks has been inspired by asyncio, Celery
and aiotasks packages.
The main advantages of the ATasks comparing with Celery:
- asynchronous task evaluation instead of synchronous tasks
- free combining of
ataskawaits inside anotherataskusingawait - easy awaiting an
ataskand getting a result - parallelization using standard asynchronous syntax
- no any restriction for recurrent
awaits
The main advantages of the ATasks comparing with aiotasks:
- easier getting a result (
awaitinstead ofasync with) - full transparency - the only difference from usual
coroutine
awaitis distributingatasksevaluation among a network - actual development
The main disadvantages comparing with Celery and aiotasks:
delay(),send(),async_call(),s()etc. syntax is not available, and will never be implemented
Usual scenarios see in the scenarios.py file.
Where the atask is evaluated
After the atask is started, it is running in one thread from the beginning
to the end. Other atasks may share the same thread in an asynchronous manner.
On the other side, another atask called from the first one may be
running on any ATasks worker, on the same as the first one, or another
worker and host, depending on the decision taken on the transport layer,
and present ATask workers connected to the same transport layer.
The point where the atask is awaited is the only point of taking
a decision, where the awaited atask should run. The
transport layer takes this decision.
The ATasks application can issue remote awaits immediately after
transport connect(). The ATask application receives remote
awaits after the activate() call of the Router.
The LoopbackTransport always passes all awaits immediately to
coroutines in the same thread. It may be used for testing purposes.
Other Transports may allow remote awaits inside a process,
or a host, or passed among a network.
The AMQPTransport allows using RabbitMQ (or analogue) to
pass remote awaits among a network to any number
of instances.
How to track atask
Every step of the RPC round trip is logged (atasks.router and
atasks.transport.backends.amqp loggers) tagged with the request's
correlation_id, so grepping a single correlation_id across client and
worker logs reconstructs the whole round trip: request published, request
received, response returning, response received (or the point at which it
stopped - see below).
On top of that, atasks.trace builds and carries, across however many atask
hops and hosts a call chain crosses, the chain of atask calls (RPC/queue/
broadcast) that led to whatever is currently running - and, unless disabled,
the ordinary await frames in between. No call arguments are ever recorded,
only call sites (file/line/function), atask/namespace/kind, a host
identification, a per-call id and a timestamp.
Every atask call chain starts at the root (the first atask called, however
it was called), and is attached to an exception the first time it is caught,
as exception.__atask_trace__ - fetch it with atasks.trace.get_trace(exc),
or get it pre-rendered as readable text (atask hops picked out from the
ordinary frames) with atasks.trace.format_trace(exc):
from atasks import trace
try:
await some_task(...)
except Exception as exc:
info = trace.get_trace(exc) # AtaskTrace, or None
print(trace.format_trace(exc)) # human-readable, ready to log
- An RPC (
@atask) failure is routed back to the caller exactly as before, with the trace attached, and is not logged by the router itself - the caller decides whether/how to log it. - An
atask_queue/atask_broadcastfailure has no caller to report back to, so it terminates where it happened, logging the full collected trace instead of a bare exceptionrepr().
Host identification, the call-depth guard, and whether/what to filter out of
the ordinary-await frames are all set on Router, and must be set before
the first direct or indirect call to get_router() for the namespace - after
that, get_router() returns the instance you constructed:
from atasks.router import Router
Router(
hostname='worker-fleet-2', # default: socket.gethostname()
max_trace_depth=1000, # atask hops only; guards against runaway recursion/cycles
trace_filter_modules=('atasks', 'backoff'), # default: none filtered
collect_await_frames=True, # False: trace holds only atask hops, no ordinary frames
)
run.py exposes the host identification as --hostname/-H.
When the atask is crashed
The awaiting coroutine will take an exception if the atask is crashed
with exception. The exception should be serializable using codec.
When the worker evaluating atask is crashed
Two distinct failure modes are handled, both without leaving the caller hanging forever:
-
Request timeout. Pass
timeout=<seconds>to@atask(or per-decorator viaoptions) to bound how long the caller waits for a reply:@atask(timeout=30) async def some_task(a): ...
If no response arrives in time - because the worker crashed mid-task, because it was never running in the first place, or because it is simply slow - the caller gets
atasks.transport.base.RequestTimeoutError(a subclass of the builtinTimeoutError/asyncio.TimeoutError). A crashed worker and a slow worker are indistinguishable from the caller's point of view - AMQP gives no signal that a consumer died mid-task, so both surface identically once the timeout elapses. Without atimeout, the historical behaviour is preserved: the caller waits forever. -
Connection loss. If the transport's own connection to the broker is lost (broker restart, network partition, the whole worker process and its connection disappearing, ...), every RPC request currently in flight on that connection is failed immediately with
atasks.transport.base.ConnectionLostError(a subclass of the builtinConnectionError) - rather than waiting for the configured timeout, or hanging past a later reconnect.AMQPTransportis built onaio_pika.connect_robust, so the connection itself keeps retrying (withreconnect_interval, default 5 seconds) in the background; this exception exists purely so an in-flight caller finds out promptly instead of discovering it much later.
Both exceptions are ordinary exceptions raised out of the awaited call, so
they compose naturally with backoff.on_exception(...) wrapped around the
@atask-decorated call site - see "Request timeout and combining @atask
with backoff" above.
How to await atask in synchronous program
There isn't a dedicated synchronous API, and none is planned - atask is an
async def coroutine like any other, so use it the same way you would use
any other coroutine from synchronous code: asyncio.run(some_task(...)) (or
loop.run_until_complete(...) if you already manage your own loop). See
atasks/run.py for exactly this pattern (aiomain is invoked via
loop.run_until_complete).
Release files for atasks 0.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| atasks-0.0.3.tar.gz | 72.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| atasks-0.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 113.0 kB
Release files / atasks-0.0.3.tar.gz
| Download URL | atasks-0.0.3.tar.gz |
|---|---|
| Size | 72.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aa07aeb7700da5afd41d140546b7a58b9cbff4a8837afff8b565077f7346b9cd
|
|
BLAKE2b-256 checksum How to use checksums |
378815e70e007dc0c43942291758a0c55bea52701f556d73b283d5a631baf40e
|
| 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 Sep 1, 2026.
Transparency logRelease files / atasks-0.0.3-py3-none-any.whl
| Download URL | atasks-0.0.3-py3-none-any.whl |
|---|---|
| Size | 41.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d0a81d32ddf7c3ba1983c0b18c1d6b37a0fe94912db288c86ae65be6299d374e
|
|
BLAKE2b-256 checksum How to use checksums |
1a6c6a43c5271d043605719207e5514ce270a7d31cc37169ad37c9305971717e
|
| 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 Sep 1, 2026.
Transparency log