FastAPI Async Task Queues for AI Data Pipelines

Build scalable, non-blocking data processing pipelines with FastAPI, Celery, and real-time streaming. Production patterns for UAE startups.

0

Your API receives a file. A user hits ‘process’. Synchronously, the endpoint waits for the full operation to complete. For small workloads, this works fine. For large-scale AI pipelines, it becomes a bottleneck. The request times out. The user sees a spinner.

Process it asynchronously instead: the endpoint queues the job and responds immediately. A separate worker pool handles the heavy lifting in parallel. Results stream back in real-time via Server-Sent Events. The API stays responsive. This is the difference between an MVP and a product that scales.

This is the architecture every AI-integrated startup in the UAE needs: FastAPI as the API layer, a task queue (Celery or Dramatiq) for background work, and Server-Sent Events (SSE) to push results back to the client without polling. We’ll build this pattern step-by-step, with connection pooling, error handling, and a deployment story.

Why Async Task Queues Matter for AI Pipelines

Document processing, embedding generation, vector database enrichment, and model inference are expensive operations. Running them in the request/response cycle limits throughput. A single long-running request ties up a worker thread. Ten concurrent requests exhaust your pool. Your API becomes a bottleneck.

An async task queue decouples work from the request. The API queues the job and returns immediately. A separate worker pool processes jobs in parallel, independent of API load. If a job takes 30 seconds, the API doesn’t care. If you need more throughput, you scale workers independently from API replicas.

For UAE startups building RAG systems, document ingestion platforms, or AI enrichment services, this pattern is non-negotiable. It’s what separates a prototype from production.

The Architecture: Three Layers

Picture this:

  • API Layer (FastAPI): Receives requests, queues jobs, returns job IDs, streams results via SSE.
  • Message Broker (Redis or RabbitMQ): Stores job messages. Celery or Dramatiq pull from it.
  • Worker Layer (Celery/Dramatiq): Processes jobs, calls your AI models, writes results to a database or cache.

The client connects to the API, gets a job ID, then connects to an SSE endpoint to stream results as they complete. No polling loops. No blocking waits.

Setting Up FastAPI with Celery

Start with dependencies:

pip install fastapi uvicorn celery redis pydantic python-dotenv

Create a Celery app:

from celery import Celery
from celery.result import AsyncResult
import os

celery_app = Celery(
    "data_pipeline",
    broker=os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
    backend=os.getenv("CELERY_BACKEND_URL", "redis://localhost:6379/1"),
)

celery_app.conf.update(
    task_serializer="json",
    accept_content=["json"],
    result_serializer="json",
    timezone="UTC",
    enable_utc=True,
)

@celery_app.task(bind=True, name="process_document")
def process_document(self, file_path: str, doc_id: str):
    try:
        # Simulate document processing
        # In production: extract text, chunk, embed, store in vector DB
        result = {"doc_id": doc_id, "status": "completed", "chunks": 42}
        return result
    except Exception as exc:
        # Log error, update task state
        self.update_state(state="FAILURE", meta={"error": str(exc)})
        raise

Now the FastAPI endpoints:

from fastapi import FastAPI, UploadFile, File, BackgroundTasks
from fastapi.responses import StreamingResponse
import asyncio
import json
from typing import AsyncGenerator

app = FastAPI()

@app.post("/process")
async def submit_processing(file: UploadFile = File(...)):
    """Queue a document for processing, return job ID immediately."""
    # Save file temporarily
    file_path = f"/tmp/{file.filename}"
    with open(file_path, "wb") as f:
        f.write(await file.read())
    
    # Queue the task
    task = celery_app.send_task(
        "process_document",
        args=(file_path, file.filename),
        task_id=f"{file.filename}_{int(time.time())}"
    )
    
    return {"job_id": task.id, "status": "queued"}

@app.get("/results/{job_id}")
async def stream_results(job_id: str) -> StreamingResponse:
    """Stream job results via Server-Sent Events."""
    async def event_generator() -> AsyncGenerator[str, None]:
        while True:
            result = AsyncResult(job_id, app=celery_app)
            
            if result.state == "PENDING":
                data = {"status": "pending", "progress": 0}
            elif result.state == "PROGRESS":
                data = result.info
            elif result.state == "SUCCESS":
                data = {"status": "completed", "result": result.result}
                yield f"data: {json.dumps(data)}

"
                break
            elif result.state == "FAILURE":
                data = {"status": "failed", "error": str(result.info)}
                yield f"data: {json.dumps(data)}

"
                break
            else:
                data = {"status": result.state}
            
            yield f"data: {json.dumps(data)}

"
            await asyncio.sleep(1)
    
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

Run the worker in a separate process:

celery -A celery_app worker --loglevel=info --concurrency=4

Connection Pooling: Reuse Connections, Don’t Create New Ones

If your tasks call a vector database or PostgreSQL, opening a fresh connection for every job degrades performance. Use connection pooling to reuse connections across tasks:

from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
import asyncpg

# PostgreSQL pool
db_engine = create_engine(
    "postgresql://user:pass@localhost/db",
    poolclass=QueuePool,
    pool_size=20,
    max_overflow=10,
    pool_recycle=3600,
)

# Vector DB (Pinecone, Weaviate, or self-hosted) connection pool
async def get_vector_db_pool():
    return await asyncpg.create_pool(
        "postgresql://user:pass@vector-db:5432/vectors",
        min_size=10,
        max_size=20,
    )

@celery_app.task(name="embed_and_store")
def embed_and_store(text: str, doc_id: str):
    """Embed text and store in vector DB using pooled connection."""
    # Get connection from pool (doesn't create new one)
    with db_engine.connect() as conn:
        # Embed using your model
        embedding = model.encode(text)
        
        # Store in vector DB
        conn.execute(
            "INSERT INTO embeddings (doc_id, embedding, text) VALUES (%s, %s, %s)",
            (doc_id, embedding.tolist(), text),
        )
        conn.commit()
    
    return {"doc_id": doc_id, "embedded": True}

Connection pooling significantly reduces overhead by reusing connections instead of creating new ones for each task. This matters at scale.

Error Handling and Retry Logic

Network calls fail. Vector databases timeout. Your task needs resilience. Celery supports automatic retries with exponential backoff:

from celery.exceptions import MaxRetriesExceededError
import logging

logger = logging.getLogger(__name__)

@celery_app.task(
    bind=True,
    name="process_with_retry",
    autoretry_for=(Exception,),
    retry_kwargs={"max_retries": 3, "countdown": 5},
    default_retry_delay=5,
)
def process_with_retry(self, data: dict):
    try:
        # Your processing logic
        result = call_external_api(data)
        return result
    except TimeoutError as exc:
        logger.warning(f"Timeout on attempt {self.request.retries}, retrying...")
        raise self.retry(exc=exc, countdown=10)  # Exponential backoff
    except Exception as exc:
        if self.request.retries < 3:
            logger.error(f"Task failed, retry {self.request.retries + 1}")
            raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1))
        else:
            logger.critical(f"Task exhausted retries: {exc}")
            raise MaxRetriesExceededError(str(exc))

This pattern retries failed tasks with exponential backoff, logs everything, and fails gracefully after max retries. For production workloads, this is essential.

Real-Time Streaming with Server-Sent Events

SSE is simpler than WebSockets for one-way server-to-client updates. The client connects and listens:

// Frontend JavaScript
const eventSource = new EventSource(`/results/${jobId}`);

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log("Job status:", data);
  
  if (data.status === "completed") {
    console.log("Results:", data.result);
    eventSource.close();
  } else if (data.status === "failed") {
    console.error("Job failed:", data.error);
    eventSource.close();
  }
};

eventSource.onerror = () => {
  console.error("Connection lost");
  eventSource.close();
};

The server pushes updates. No polling. No unnecessary requests. The user sees progress in real-time.

Deployment to Azure Container Apps

Package your API and workers in separate containers. Create a docker-compose file locally, then deploy to Azure Container Apps:

version: "3.8"
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - CELERY_BROKER_URL=redis://redis:6379/0
      - CELERY_BACKEND_URL=redis://redis:6379/1
    depends_on:
      - redis
    command: uvicorn main:app --host 0.0.0.0 --port 8000

  worker:
    build: .
    environment:
      - CELERY_BROKER_URL=redis://redis:6379/0
      - CELERY_BACKEND_URL=redis://redis:6379/1
    depends_on:
      - redis
    command: celery -A celery_app worker --loglevel=info --concurrency=4

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

For Azure, push images to Azure Container Registry, then create Container Apps for the API and worker with auto-scaling rules. Set replica count for the worker based on queue depth.

Monitoring and Observability

You can’t fix what you can’t see. Add basic monitoring with Prometheus:

from prometheus_client import Counter, Histogram, start_http_server
import time

# Metrics
task_counter = Counter("tasks_total", "Total tasks", ["name", "status"])
task_duration = Histogram("task_duration_seconds", "Task duration", ["name"])

@celery_app.task(name="monitored_task")
def monitored_task(data: dict):
    start = time.time()
    try:
        result = do_work(data)
        task_counter.labels(name="monitored_task", status="success").inc()
        return result
    except Exception as exc:
        task_counter.labels(name="monitored_task", status="failure").inc()
        raise
    finally:
        duration = time.time() - start
        task_duration.labels(name="monitored_task").observe(duration)

# Expose metrics on /metrics for Prometheus
if __name__ == "__main__":
    start_http_server(8001)

Scrape these metrics with Prometheus, visualize in Grafana. Track task success rates, latencies, and queue depth. Set alerts when queue depth grows or error rates spike.

Scaling Considerations

As load grows, tune these levers:

  • Worker concurrency: More workers equals more parallel tasks. Start with 4, scale based on CPU and I/O.
  • Task timeout: Set explicit timeouts to prevent zombie tasks. Use Celery’s time_limit parameter.
  • Queue priority: Route urgent jobs to a high-priority queue, batch jobs to a low-priority queue.
  • Result retention: Don’t keep results forever. Celery can auto-delete old results. Set result_expires to 3600 seconds.

For a typical UAE startup processing 1000 documents per day, 4 workers with 2 concurrency each handles the load. As you scale to 10,000 documents, add workers and increase concurrency. Monitor queue depth and adjust.

Patterns to Avoid

Blocking I/O in tasks: If your task calls an API without async support, use requests with timeouts, not bare blocking calls. Better yet, use httpx with async/await.

Forgetting task idempotency: If a task retries, it might run twice. Design tasks to be idempotent: storing the same embedding twice should be a no-op, not a duplicate.

No dead-letter queue: Tasks that fail repeatedly should be captured for manual inspection. Add a dead-letter queue to track failures.

Tight coupling to Redis: Redis is great for development but consider RabbitMQ for production. It’s more robust and supports priority queues natively.

Putting It Together

Your startup now has a production-grade data pipeline. A user uploads a document. The API queues it in 10ms. A worker picks it up, extracts text, generates embeddings, stores them in your vector database. The client streams progress via SSE. Meanwhile, your API is free to handle the next request.

This is the foundation for scalable AI products. RAG systems, document enrichment, batch inference, real-time analytics, all built on the same async, queue-driven pattern. As your UAE startup grows from MVP to scale-up, this architecture scales with you.

Start small: FastAPI plus Celery plus Redis locally. Deploy to Azure Container Apps. Monitor with Prometheus. As traffic grows, add workers, tune concurrency, migrate to RabbitMQ if needed. The pattern remains consistent.

What’s the difference between Celery and Dramatiq?

Both are task queue libraries for Python. Celery is older, more feature-rich, and heavier. Dramatiq is newer, lighter, faster for simple use cases, and easier to reason about. For most UAE startups, Dramatiq is a solid choice. Celery wins if you need complex routing, priority queues, or task chaining.

Do I need Redis or can I use RabbitMQ?

Either works. Redis is simpler to set up and faster for small-to-medium scale. RabbitMQ is more robust, supports message persistence out of the box, and scales better when you have millions of tasks. Start with Redis. Migrate to RabbitMQ when queue reliability becomes critical.

How do I handle long-running tasks that take hours?

Set explicit task timeouts and break long work into smaller subtasks. A 4-hour embedding job becomes 100 subtasks of 2 minutes each. Chain them with Celery’s chain or group primitives. This way, if one subtask fails, you retry only that one, not the whole 4 hours.

Can I use FastAPI’s native async/await instead of Celery?

FastAPI’s async is for I/O-bound work: API calls, database queries. It doesn’t parallelize CPU-bound work like embedding generation or model inference. For those, you need a separate worker pool. Celery gives you that. Use FastAPI async for API endpoints, Celery for background jobs.

How do I prevent duplicate processing if a task retries?

Make tasks idempotent. Use a unique constraint on your database. For embeddings, use (doc_id, chunk_id) as the primary key. If the same task runs twice, the second insert fails silently or updates the existing row. In Celery, use task_id to make retries deterministic.

Leave a Reply

Your email address will not be published. Required fields are marked *