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.
Stable version from the PyPi package repository
pip install atasksLast development version from the GitHub source version control system
pip install git+git://github.com/nnseva/atasks.git
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':
import my_package.tasks # import every @atask/@atask_queue/@atask_broadcast module first
router = get_router()
await router.activate(transport)See Client and Server below for why that import order matters.
All objects of the atasks package may be instantiated in separate namespaces. The default namespace has a name "default".
Use the namespace parameter of any atasks class constructor to identiy the namespace where this instance should be instantiated.
The namespace parameter may be used in atask decorators to identify, in which namespace the atask is registered.
The get_route, get_transport, or get_codec functions always return namespace-specific route, transport, and codec instances. Use the namespace parameter to select, for which namespace you want to get the instance.
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.
You can await atask declared in one namespace, from another.
@atask(namespace='one')
async def some_task():
await some_other_task()
...
@atask(namespace='other')
async def some_other_task():
....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.
Every namespace has it's own single Router instance.
You can create a Router instance with non-default constructor parameters if necessary. This should be done before any call to other atasks parts, including decorators (i.e. even before import of modules which use these decorators).
Creating a Router instance immediately registers this instance in the namespace.
The Router constructor parameters are:
namespace- name of the namespace (see Namespaces). The default namespace has a name"default".hostname- name of the host. You can identify your host explicitly to have this name in traces (see How to track atask). The default hostname is provided by the system.max_trace_depth- maximum trace depth (number of recursiveataskcalls), default is 1000 (see How to track atask). If the trace is deeper, a special exception will be thrown.collect_await_frames- whether to collect "usual" await frames betweenataskcalls in the trace (see How to track atask), default isTrue.
User can also inherit atasks.router.Router and create an own
router implementation if necessary. Create your own instance of the Router successor before any call to other atasks parts, including decorators (i.e. before import of modules which use these decorators).
The get_router() function returns the namecpase's Router instance.
In many cases, you don't need to create your own Router instance. The first get_router() call will create the default instance of the Router for the correspondent namespace, if the instance is not exist at this time. All other calls of the get_router() to the same namespace will return the same instance.
Use the get_router() function everywhere the instance of the Router is required.
from atasks.router import get_router
...
router = get_router()Every namespace has it's own Router instance. Use the namespace name as an argument to get namespace-specific Router instance:
from atasks.router import get_router
...
router = get_router(namespace='my-specific-namespace')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.
Every namespace has it's own codec instance. Use the namespace codec constructor parameter to register the codec in the non-default namespace.
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 package provides two transport implementations:
atasks.transport.base.LoopbackTransportatasks.transport.backends.amqp.AMQPTransport
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.
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.
See "Request timeout and combining @atask with backoff" below for how
AMQPTransport surfaces RPC timeouts and connection loss to the caller.
See AMQP-TRANSPORT-TOPOLOGY.md for the exact
exchange/queue/routing-key layout AMQPTransport creates - useful when
monitoring or administering the broker.
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):
...
def is_connected(self):
...
async def send_request(self, name, content):
...
async def publish_event(self, name, content):
...
async def publish_broadcast(self, name, content):
...
async def _register_request_callback(self, name, callback):
...
async def _register_event_callback(self, name, callback):
...
async def _register_broadcast_callback(self, name, callback):
...
async def _unregister_request_callback(self, name):
...
async def _unregister_event_callback(self, name):
...
async def _unregister_broadcast_callback(self, name):
...The four _register_*_callback/_unregister_*_callback pairs are protected -
Router.activate()/deactivate() are their only caller, once per name known
to the router at the moment activate() runs (see Client and
Server below). Library users never call them directly.
The transport determines, what the role is your application instance plays: client or server.
If your application instance is a client, requesting other instances through the atask, you need to only connect the transport.
If your application is a server, listening to atask requests, events, and broadcasts, you also need to activate the transport on the Router instance.
Client Application:
transport = AMQPTransport()
await transport.connect()Server Application:
transport = AMQPTransport()
await transport.connect()
import my_package.tasks # runs every @atask/@atask_queue/@atask_broadcast decorator
router = get_router()
await router.activate(transport)router.activate(transport) subscribes, once, to every @atask,
@atask_queue and @atask_broadcast name already registered in this
namespace at the moment it is called - each gets its own subscription (see
AMQP Transport below for what that looks like on the
wire), never a single catch-all one. Because of that, every module
containing @atask/@atask_queue/@atask_broadcast decorators this
instance should serve must be imported before router.activate(transport) is
called - not after. Registering a new one afterwards raises
atasks.router.LateRegistration instead of being silently ignored, so a
wrong import order fails loudly rather than quietly dropping messages for
whatever was registered too late.
router.deactivate() unsubscribes everything the matching activate() call
subscribed.
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):
...The name influences the network name used to identify the atask when it is requested.
Both bare (@atask) and parameterized (@atask(...)) forms work, and so do the equivalent forms of @atask_queue and @atask_broadcast described below.
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)
...@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).
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 simply by having imported the module with the
@atask_queue decorator before calling router.activate(transport) - see
Client and Server above; there is no separate
per-task-queue activation call to make.
Every instance whose @atask_queue shares the same name ends up bound 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).
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):
...As with @atask_queue above, subscribing happens automatically for every
@atask_broadcast name that was already registered when router.activate(transport)
was called - see Client and Server.
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.
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.
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]Each referenced file or module is loaded once, regardless of how many
namespaces it registers @atasks into (see Namespaces). If it
defines aiomain, that coroutine is evaluated; parsed command-line options
(including the parsed -N/--namespace list, see below) are passed to it as
keyword arguments - see dev/tests/scenarios.py for an example.
Every namespace the run is meant to touch - even a single one - is configured
with its own -N/--namespace SPEC, repeatable, one per namespace. SPEC is
a comma-separated key=value list:
| Key | Default | Meaning |
|---|---|---|
name |
default |
Namespace name |
mode |
loopback |
client - only connects the transport to send requests/events/broadcasts from the aiomain function of the scenario. server - additionally activates this namespace's Router against its transport, executes aiomain and waits to serve requests, loopback - connects and activates, but doesn't wait for incoming requests and stops immediately after aiomain executed. |
transport |
loopback |
loopback or amqp |
url |
(none) | Passed to the transport, e.g. the broker URL for transport=amqp |
hostname |
auto-detected | See the Router constructor's hostname |
max-trace-depth |
1000 |
See the Router constructor's max_trace_depth |
trace-filter-modules |
none filtered | :-separated dotted module name prefixes - see the Router constructor's trace_filter_modules |
collect-await-frames |
true |
true/false - see the Router constructor's collect_await_frames |
If any configured namespace is in mode server, the process activates every
server and loopback namespace's Router and then blocks,
"Listening for requests" - regardless of how many
other namespaces are client or loopback.
The process runs any scenarios' aiomain()s to completion.
Omitting -N/--namespace entirely is equivalent to a single
-N name=default - a namespace named default over the loopback
transport in the loopback (default) mode:
python -m atasks.run dev.tests.scenarios --verbosity 3A server mode paired with the amqp transport lets one process act
as both server and client for that namespace, and wait for incoming requests:
python -m atasks.run dev.tests.scenarios -N mode=server,transport=amqp --verbosity 3A client mode paired with the amqp transport lets one process act
as a pure client, executing it's aiomain() function to request server(s):
python -m atasks.run dev.tests.scenarios -N mode=client,transport=amqp --verbosity 3A loopback mode is similar to the loopback mode, but doesn't wait for incoming
requests, just executes aiomain() and exits:
python -m atasks.run dev.tests.scenarios -N mode=loopback,transport=amqp --verbosity 3You can use the loopback mode to create necessary durable AMQP queues before the service is deployed for the very first time on this AMQP server (see also AMQP Transport Topology)
Several independent namespaces, each with its own transport/mode, in one process:
python -m atasks.run my_scenarios.py \
-N name=orders,mode=server,transport=amqp,url=amqp://broker/,hostname=worker-1 \
-N name=billing,mode=client,transport=amqp,url=amqp://broker/Other options, independent of any namespace:
-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 --helpNote that if you use a dedicated server process instance reached from
another process, you should not use the loopback transport for it (it never
reaches outside its own process) - use amqp (or another 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.
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.
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.
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 these as the hostname, max-trace-depth, trace-filter-modules
and collect-await-frames keys of its per-namespace -N/--namespace SPEC -
see Commands.
The awaiting coroutine will take an exception if the atask is crashed
with exception. The exception should be serializable using codec.
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.
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).