FastAPI¶
from fastapi import FastAPI
from tasks import app as jobs, generate_pdf
api = FastAPI(lifespan=jobs.lifespan(worker=True))
@api.post("/documents/{document_id}/pdf")
async def render(document_id: int) -> dict:
job = await generate_pdf.aenqueue(document_id)
return {"job": job.id}
uvicorn main:api is the whole deployment. The lifespan connects when the server starts, runs a
worker inside it, and on shutdown lets in-flight jobs finish before closing. Plain def endpoints
can call enqueue() too, on every backend.
worker=True takes the options of app.worker(), such as concurrency, queues and
shutdown_timeout. Under uvicorn --workers 4 each process runs its own worker on the same queue,
and cron still fires once. If you already have a lifespan, nest it:
@asynccontextmanager
async def lifespan(api: FastAPI):
async with jobs.serve(worker=True):
yield
The process exits within shutdown_timeout (30 s), or 1.25 s when that is shorter, so that
releasing still has its second: a last moment, half a second at most, is kept for closing
connections, and of the rest running jobs and a claim still in flight get the first three quarters
and releasing what is still running the last, one second at least; a second Ctrl-C under a server
does not cut it short. Under tinyjobs worker it does: running jobs are cancelled and go back to
the queue at once, even when one blocks the event loop; a job that swallows every interrupt makes
the process exit a second later with status 130, leaving its job to the reaper. A def job whose
thread is still running is not released; the worker waits for it and records its outcome within a
quarter of the timeout, one second at least, after the thread returns. Keep it under your
orchestrator's grace period: docker stop allows 10 s, and past it the process is killed and its
jobs wait out their leases instead of going straight back to the queue. Under tinyjobs worker, a
worker that cannot reach its backend when it starts, or whose setup fails, exits with a status other
than 0, so let the orchestrator restart it: 2 for an error tinyjobs raises itself, such as a
configuration or a store it refuses, and 1 for any other.
Starlette takes the same lifespan. Leave out worker=True and the server only enqueues, with
tinyjobs worker --app tasks:app running the jobs elsewhere.
The worker shares the process with your API: an async task that blocks the loop stalls requests too,
and CPU-heavy def tasks compete for the GIL unless the build is free-threaded. That is the point
where a separate worker pays off. Numbers in Measured.