Part 7 - Background Tasks¶
Series: Tutorial index · Part 6 · You are here: Part 7 · Part 8
In Part 4 you wired PDF ingestion into a FastAPI BackgroundTask. That runs in the same process as the web server - if ingestion is slow or crashes, it blocks the event loop and takes the web server down with it. In Part 7 you replace it with a Dramatiq actor running in a separate worker process. The web server stays responsive; the worker can be restarted independently; and you get a periodic task that re-checks stale documents on a schedule.
By the end of this part uploads enqueue a Dramatiq message and return immediately. A separate worker process handles extraction, embedding, and vector storage. A periodiq scheduler fires a re-index check every hour.
Prerequisites¶
- Part 6 complete (chat UI working)
- Valkey/Redis running on port 6379 (the same instance from Part 0)
1. Install the scheduler extra¶
The agent preset already includes fast-agent-stack[tasks] (Dramatiq) in the generated pyproject.toml, so it was installed when you ran uv pip install -r pyproject.toml in Part 1. You only need to add periodiq for the scheduler:
2. Replace the scaffold placeholder in docqa/tasks.py¶
The agent preset already generated docqa/tasks.py with an example_task stub. Replace the entire file with the ingestion actor and periodic re-index task:
import asyncio
import logging
import os
import uuid
import dramatiq
from periodiq import PeriodiqMiddleware, cron
from fast_agent_stack.database import configure_engine, get_async_session
from fast_agent_stack.rag import RagService, get_embedding_provider, get_vector_store
from fast_agent_stack.tasks import configure_broker
from .ai.tools.ingestion import COLLECTION
from .models import Document
from .settings import get_settings
logger = logging.getLogger(__name__)
_settings = get_settings()
# Wire Dramatiq to Redis and initialize the database engine once, when the
# worker process imports this module.
_broker = configure_broker(_settings)
_broker.add_middleware(PeriodiqMiddleware(skip_delay=30))
configure_engine(_settings.database_url)
@dramatiq.actor(queue_name="ingestion")
def ingest_document(doc_id: str, file_path: str, filename: str, content_type: str) -> None:
"""Dramatiq actor: extract, embed, and store a PDF in Qdrant."""
asyncio.run(_ingest_async(doc_id, file_path, filename, content_type))
async def _ingest_async(doc_id: str, file_path: str, filename: str, content_type: str) -> None:
rag = RagService(
embedding=get_embedding_provider(_settings),
vector_store=get_vector_store(_settings),
)
with open(file_path, "rb") as f:
data = f.read()
os.unlink(file_path)
status = "ingested"
vector_doc_id = None
try:
result = await rag.ingest_file(
COLLECTION,
data,
filename=filename,
content_type=content_type,
document_id=doc_id,
metadata={"filename": filename},
)
vector_doc_id = result.document_id
except Exception:
logger.exception("Ingestion failed for document %s", doc_id)
status = "failed"
async for session in get_async_session():
doc = await session.get(Document, uuid.UUID(doc_id))
if doc is not None:
doc.status = status
doc.vector_doc_id = vector_doc_id
await session.commit()
@dramatiq.actor(periodic=cron("0 * * * *"))
def reindex_stale() -> None:
# Exercise for the reader: query documents with status="pending"
# older than one hour and re-enqueue them via ingest_document.send().
logger.info("Periodic re-index check triggered")
A few things to notice:
configure_brokerandconfigure_engineat module level: both are called once when the worker importsdocqa.tasks. By the time_ingest_asyncruns, the database engine is already initialized andget_async_session()is ready to use.- Why
asyncio.run()in the actor: Dramatiq actors are plain synchronous functions - the worker process is not inside any async event loop.asyncio.run()creates a fresh event loop for each actor invocation, which is the correct pattern. file_pathnotbytes: Dramatiq serializes actor arguments as JSON. Rawbytesare not JSON-serializable. Instead the route saves the file to a temp path and passes the path string; the actor reads the file and deletes it after use.PeriodiqMiddleware: required for the@dramatiq.actor(periodic=...)decorator to work.skip_delay=30lets the scheduler fire the first run 30 seconds after startup instead of waiting for the first cron interval.
3. Update the upload route¶
Open docqa/routes.py. The upload endpoint loses two parameters compared to Part 4: BackgroundTasks (no longer needed - the actor handles it) and rag: RagService = Depends(get_rag_service) (the worker constructs its own RagService directly, since FastAPI dependencies only work inside request handlers). It saves the uploaded file to a temp path and enqueues the actor:
import tempfile
from .tasks import ingest_document
@router.post("/documents/upload", response_model=DocumentResponse, status_code=202)
async def upload_document(
file: UploadFile,
session: AsyncSession = Depends(get_async_session),
current_user: User = Depends(get_current_user),
) -> Document:
if file.content_type != "application/pdf":
raise HTTPException(status_code=415, detail="Only PDF files are supported")
data = await file.read()
doc = Document(title=file.filename or "Untitled", status="pending")
session.add(doc)
await session.commit()
await session.refresh(doc)
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
tmp.write(data)
tmp_path = tmp.name
ingest_document.send(str(doc.id), tmp_path, file.filename or "upload.pdf", file.content_type or "application/pdf")
return doc
Also remove from routes.py: the BackgroundTasks import, the get_rag_service import, and the _ingest helper function - all replaced by docqa/tasks.py.
4. Run three processes¶
You now need three terminal windows:
fas worker docqa.tasks imports docqa.tasks, which calls configure_broker to connect to Redis, then processes messages from the ingestion queue. fas scheduler docqa.tasks imports the same module and fires actors marked with periodic=cron(...) on schedule.
5. Test the flow¶
Open http://127.0.0.1:8000 in a browser, log in, and upload a PDF using the chat UI from Part 6.
The upload returns immediately - the document card shows "pending". Switch to Terminal 2 and watch the worker log; you will see the ingestion run and "ingested" written back to the database. Switch back to the browser and ask a question about the PDF - the agent should retrieve relevant chunks and stream an answer.
Decoupling in action: stop the worker (Ctrl-C in Terminal 2) and upload another PDF. The upload still returns 202 immediately - the message is sitting in Redis. Restart the worker; it picks up the queued message and ingests the file without any intervention from the web server. The web server never stalled.
What you built¶
fast_agent_stack/tasks/__init__.py(already part of the framework) - publicconfigure_brokerthat wires Dramatiq to Redisdocqa/tasks.pywith aningest_documentDramatiq actor and areindex_staleperiodic task- Updated
POST /documents/uploadthat enqueues via.send()instead of running ingestion in-process fas workerandfas schedulerCLI commands to run the worker and scheduler processes
Next steps¶
In Part 8 you will add rate limiting to the chat endpoint, wire up OpenTelemetry tracing with Jaeger, and package everything in a Docker Compose file ready for deployment.