Fire-and-forget async work inside a request handler: result tracking, callbacks, drain-on-shutdown, supervision, and the unbounded registry you need to know about.
Background Tasks
Section titled “Background Tasks”BackgroundTask runs an async function without waiting for it. The
request returns immediately; the work continues in the same process, on
the same event loop.
from sillo.work.background import BackgroundTask
@app.post("/signup")async def signup(request, response): user = await create_user(request.validated_data) BackgroundTask.run(send_welcome_email, user.email) return response.json({"id": user.id}, status_code=201)The user gets their 201 in fifty milliseconds instead of waiting two seconds for an SMTP handshake.
When to reach for this, and when not to
Section titled “When to reach for this, and when not to”Use a background task for work that is fast, roughly idempotent, and genuinely optional: sending a notification, warming a cache, writing an analytics row, invalidating a CDN path.
The other limit is that background tasks share the event loop with your request
handlers. CPU-bound work (image resizing, PDF generation, large JSON parsing)
blocks every concurrent request in that process for as long as it runs. Hand
that to a worker process, or to a thread with asyncio.to_thread.
Launching
Section titled “Launching”bt = BackgroundTask.run(async_function, arg1, arg2, kwarg=value)bt = BackgroundTask.run_sync(sync_function, arg) # wraps a sync callablerun() requires a running event loop and raises RuntimeError: BackgroundTask.run() requires an async context otherwise, so it works inside
handlers and startup hooks, and fails at module import.
run_sync() wraps a non-async callable in a coroutine. Note that this
does not move the work off the event loop: a blocking sync function
still blocks everything. For genuinely blocking work, offload it:
BackgroundTask.run(asyncio.to_thread, resize_image, path)Options
Section titled “Options”bt = BackgroundTask.run( process_upload, file_id, name=f"upload-{file_id}", timeout=120, metadata={"user_id": user.id}, on_success=mark_complete, on_failure=alert_ops, on_done=record_metric,)| Option | Effect |
|---|---|
name | Label used in to_dict() and logs; defaults to func.__name__ |
timeout | Seconds before the task is cancelled and marked failed |
metadata | Arbitrary dict carried on the TaskResult |
on_success | Called with the TaskResult on success |
on_failure | Called with the TaskResult on failure |
on_done | Called on both: registered as success and failure |
Callbacks receive a TaskResult, not the return value. Exceptions raised
inside a callback are logged and swallowed; a broken callback never takes
down the task.
Inspecting a task
Section titled “Inspecting a task”bt.id # strbt.name # strbt.done # boolbt.running # boolbt.result # TaskResult | Nonebt.elapsed # float seconds since launchbt.to_dict() # id, name, done, running, elapsed, status, resultAll of these except to_dict() are properties, not methods. bt.done()
raises TypeError: 'bool' object is not callable. A mistake that is easy to
make and easy to spot once you know it.
try: value = await bt.wait(timeout=30)except WorkError as exc: logger.error("task failed: %s", exc)Awaiting a background task in the handler that launched it defeats the purpose. If you need the value before responding, await the function directly.
Draining on shutdown
Section titled “Draining on shutdown”Without a drain, a SIGTERM cancels every in-flight task mid-execution.
drain() gives them a bounded window to finish.
@app.on_shutdownasync def finish_background_work(): summary = await BackgroundTask.drain(timeout=10, cancel_remaining=True) logger.info("drained background tasks: %r", summary) # {'total': 12, 'completed': 11, 'cancelled': 1}Pick a timeout shorter than your orchestrator’s grace period. Kubernetes
defaults to 30 seconds between SIGTERM and SIGKILL, so a 10-second drain
leaves room for the rest of shutdown. A drain longer than the grace period is
worse than none: the process gets killed mid-drain and you lose the tasks
anyway, plus you delayed the rollout.
cancel_remaining=False leaves stragglers running, which only helps if
something else is keeping the process alive.
Monitoring
Section titled “Monitoring”@app.get("/admin/background")async def background_status(request, response): return response.json(BackgroundTask.count())Read the numbers correctly given the registry behaviour above: running and
pending are live gauges, total and done only ever grow. A running count
that climbs and never falls means tasks are hanging. Check whether they have
timeouts.
Supervision
Section titled “Supervision”Supervisor keeps a long-lived task alive by restarting it when it
fails. It is for daemon-shaped work: a queue consumer, a pub/sub
listener, a polling loop.
import asyncio
from sillo.work.background import RestartPolicy, Supervisor
supervisor = Supervisor( consume_events, RestartPolicy.EXPONENTIAL_BACKOFF, max_restarts=5, base_delay=1.0, max_delay=60.0,)
@app.on_startupasync def start_consumer(): app.state["consumer"] = asyncio.create_task(supervisor.start())
@app.on_shutdownasync def stop_consumer(): supervisor.stop() await supervisor.wait(timeout=5)| Policy | On failure | On success |
|---|---|---|
NEVER | Stop | Stop |
ON_FAILURE | Restart, up to max_restarts | Stop |
ALWAYS | Restart, up to max_restarts | Restart immediately |
EXPONENTIAL_BACKOFF | Restart, up to max_restarts | Restart immediately |
max_restarts=0 means unlimited.
Two things the table makes visible. ALWAYS and EXPONENTIAL_BACKOFF behave
identically. The delay min(base_delay * 2 ** restarts, max_delay) is applied
on the failure path regardless of which you pick, so the names describe intent
rather than behaviour. And on the success path both restart with no delay
at all: a supervised function that returns quickly becomes a busy loop that
pins a core. Supervise functions that are supposed to run forever, and put the
sleep inside them.
The restart counter resets only when start() is called again, so a task
that fails five times over a week still exhausts max_restarts=5. For
genuinely long-lived processes, prefer max_restarts=0 plus alerting on
the restart count from to_dict().
A worked example
Section titled “A worked example”An export endpoint that responds immediately, tracks progress in the database rather than in memory, and degrades sanely if the process dies.
@app.post("/exports")async def start_export(request, response): export = await Export.create( user_id=request.user.id, status="pending", requested_at=now() )
BackgroundTask.run( run_export, export.id, name=f"export-{export.id}", timeout=600, on_failure=lambda result: logger.error( "export %s failed: %s", export.id, result.error ), ) return response.json({"export_id": export.id, "status": "pending"}, status_code=202)
async def run_export(export_id: int) -> None: await Export.filter(id=export_id).update(status="running") try: url = await build_export_file(export_id) except Exception: await Export.filter(id=export_id).update(status="failed") raise await Export.filter(id=export_id).update(status="done", url=url)The state lives in the exports table, so a restart leaves a row stuck in
running rather than losing the request entirely, and a periodic scheduled
job can find rows stuck in running for over an hour
and re-queue them. That reconciliation loop is what makes a background task
acceptable for work that matters; without it, use the queue.
What not to do
Section titled “What not to do”Do not use a background task for work that must happen. A restart loses it.
Do not run CPU-bound work in one. It blocks every concurrent request in the process.
Do not call bt.done(). It is a property.
Do not catch the original exception from wait(). It is wrapped in
WorkError.
Do not pass name, timeout, or metadata to a function that
declares them. They are consumed by the constructor.
Do not read count()["total"] as a live gauge. It only grows.
Do not fire background tasks per request on a high-traffic endpoint. The registry retains them.
Do not await supervisor.start() in a startup hook. It never
returns.
Do not supervise a fast-returning function with ALWAYS or
EXPONENTIAL_BACKOFF. It becomes a busy loop.
Performance notes
Section titled “Performance notes”A background task is one asyncio.Task plus a Task wrapper, cheap to create,
and it competes with request handling for the same event loop. Hundreds are
fine; thousands of concurrent tasks each holding a database connection will
exhaust the pool long before they exhaust the CPU.
Set timeout on anything touching the network. Without one, a hung HTTP
call keeps a task, its memory, and any connection it holds alive for the
life of the process.
The registry behaviour above is the failure mode that presents as “memory
grows steadily, restarts fix it”. Check count()["total"] against your
request count if you are debugging that shape of problem.
API reference
Section titled “API reference”| Member | Signature | Notes |
|---|---|---|
BackgroundTask.run | (func, *args, name=None, timeout=None, metadata=None, on_success=None, on_failure=None, on_done=None, **kwargs) | Requires a running loop |
BackgroundTask.run_sync | same | Wraps a sync callable; does not offload it |
.wait | (timeout=None) -> Any | Raises WorkError on failure |
.cancel | () -> bool | False if already finished |
.done / .running / .result / .id / .name / .elapsed | properties | Not methods |
.to_dict | () -> dict | |
BackgroundTask.drain | (timeout=10.0, cancel_remaining=True) -> dict | Does not clear the registry |
BackgroundTask.count | () -> dict | total and done only grow |
Supervisor | (func, policy=ON_FAILURE, *, max_restarts=3, base_delay=1.0, max_delay=60.0, name=None) | |
Supervisor.start | (*args, **kwargs) | Blocks until stopped |
Supervisor.stop / .wait / .to_dict | stop() cancels the current task |
Related
Section titled “Related”- Work Overview: choosing between the background, queue, and scheduler layers
- Queue System: durable, retryable, cross-process work
- Jobs: the dispatchable job class
- Scheduler: periodic work, including reconciliation loops
- Concurrency: how sillo schedules tasks on the loop
- Startup & Shutdown: where
drainbelongs