Consuming a message queue
A common production pattern is a queue consumer: a long-running task that pulls messages from an external message queue (such as AWS SQS) and processes each message concurrently. Flyte 2 expresses this naturally by combining three building blocks you have already seen:
- Async tasks: the consumer loop is an
async deftask that awaits I/O against the queue. -
Fanout: each received message is dispatched to its own
process_messageaction withasyncio.create_task(), so processing runs in parallel across the cluster. -
Reusable containers: a
ReusePolicykeeps a warm pool of replicas ready, so messages are processed without per-message container cold-start.
The complete, runnable source for this example, a producer (generator.py) and a consumer
(processor.py), lives in the Flyte SDK repository under
examples/queue-reader.
This example reads from AWS SQS and therefore requires an SQS queue and AWS credentials
available to the running task (here the queue is passed as an ARN via the QUEUE_ARN
environment variable). The Flyte pattern shown below applies to any external queue: swap
the SQS client calls for your queue’s client.
The consumer
Define the task environment
The consumer runs in a
reusable TaskEnvironment.
replicas=3 keeps a warm pool of at least two replicas to avoid starvation while the parent
consumer task occupies one, and idle_ttl=300 shuts the pool down after five minutes of
inactivity.
The image is built from the script’s own inline dependencies with
flyte.Image.from_uv_script, plus the unionai-reuse runtime library that reusable containers
require:
# /// script
# requires-python = "==3.13"
# dependencies = [
# "flyte",
# "aioboto3>=11.3.0",
# "asyncio",
# ]
# ///
import asyncio
import json
import os
from typing import List
import aioboto3
import flyte
env = flyte.TaskEnvironment(
name="sqs_processor",
resources=flyte.Resources(memory="500Mi", cpu=1),
image=flyte.Image.from_uv_script(
__file__,
name="flyte",
).with_pip_packages("unionai-reuse>=0.1.3"),
reusable=flyte.ReusePolicy(
replicas=3, # 1 for the consumer loop + 2 workers, so processing never starves
idle_ttl=300, # Idle time to keep the task environment alive
),
)
# The queue is passed as an ARN via the QUEUE_ARN environment variable.
DEFAULT_QUEUE_ARN = os.getenv("QUEUE_ARN")
def get_queue_url_from_arn(queue_arn: str) -> str:
"""Convert an SQS ARN to a queue URL."""
parts = queue_arn.split(":")
region = parts[3]
account = parts[4]
queue_name = parts[5]
return f"https://sqs.{region}.amazonaws.com/{account}/{queue_name}"Process a single message
Each message is handled by its own task. These tasks run in parallel across the reusable pool,
bounded by the number of worker replicas: with replicas=3 and the default concurrency=1, the
parent consumer loop occupies one replica and the other two each process a single message at a
time, so about two messages are handled concurrently. To let a single replica handle more than one
message at once, raise concurrency above 1:
@env.task
async def process_message(message: dict) -> str:
"""Process a single message asynchronously and return the extracted word."""
body = json.loads(message["Body"])
word = body.get("word", "unknown")
print(f"Task Processing message {body.get('message_id')}: {word}")
return wordThe consumer loop
The driver task long-polls the queue, and for each message it receives it dispatches a
process_message action with asyncio.create_task() rather than awaiting it inline. This
is what fans the work out in parallel. It deletes each message once processing has started, then
awaits all dispatched tasks with asyncio.gather():
@env.task
async def main(queue_arn: str = DEFAULT_QUEUE_ARN, max_messages: int = 10) -> List[str]:
queue_url = get_queue_url_from_arn(queue_arn)
session = aioboto3.Session(region_name="us-east-2")
results = []
tasks = []
messages_received = 0
async with session.client("sqs") as sqs:
while messages_received < max_messages:
response = await sqs.receive_message(
QueueUrl=queue_url,
AttributeNames=["All"],
MaxNumberOfMessages=1, # one message at a time
WaitTimeSeconds=20, # long-polling timeout (max 20 seconds)
)
messages = response.get("Messages", [])
if not messages:
continue
message = messages[0]
messages_received += 1
# Fan out: dispatch processing as a parallel action.
process_task = asyncio.create_task(process_message(message))
tasks.append(process_task)
# Delete the message once we've started processing it.
await sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"])
# Wait for all dispatched processing tasks to complete.
if tasks:
completed_tasks = await asyncio.gather(*tasks)
results.extend(completed_tasks)
return resultsRun it
Initialize Flyte from your config and run the consumer remotely:
if __name__ == "__main__":
flyte.init_from_config()
run = flyte.run(main, queue_arn=DEFAULT_QUEUE_ARN, max_messages=10)
print(run.url)The producer
To exercise the consumer, the example includes a standalone
generator.py that
pushes ten JSON messages onto the same SQS queue with boto3. It is an ordinary Python script,
not a Flyte task. Any producer that writes to the queue will do.
Notes and gotchas
- Delete after receive, not after processing completes. The example deletes each message as
soon as it dispatches the processing task. If a
process_messageaction can fail and you need at-least-once semantics, delete the message only after the task succeeds instead. max_messagesbounds the run. The consumer loop here stops aftermax_messages. For a continuously running consumer, drive it on a trigger or remove the bound and manage the task lifecycle explicitly.- Reusable containers require a Union backend. See
Reusable containers for the
ReusePolicyparameters (replicas,concurrency,idle_ttl,scaledown_ttl) and their capacity math.