Background work¶
Work outside requests, shared by every process: jobs at given times, a task queue, a leader, locks, messages to every process, and an outbox for the messages a change announces.
In an app: the example. Every class and function: the API reference.
Scheduled jobs¶
FastAPIScheduler runs jobs every N seconds or by a cron expression, in every process of the
app, and each tick once across them all:
from fastapi_alcazaba import FastAPIScheduler
scheduler = FastAPIScheduler() # FastAPICache's backend; or FastAPIScheduler(cache)
@scheduler.every(minutes=5)
async def refresh_catalog() -> None: ...
@scheduler.cron("0 3 * * *", tz="Europe/Madrid")
def nightly_report() -> None: ... # sync jobs run in a thread
@scheduler.every(seconds=30, exclusive=False)
async def reload_settings() -> None: ... # in every process: what each keeps in memory
app = FastAPI(lifespan=scheduler.lifespan) # or `async with scheduler:` in your lifespan
- Once per tick: at each tick, the processes claim it in the backend, with an atomic counter
named after the job and the tick, and only the first runs it. A lock held while the job runs
keeps a run that outlasts its interval from overlapping the next tick in another process
(memcached can't hold locks: there, each tick still runs once, but long runs may overlap).
The ticks are the same in every process:
every(minutes=5)runs at :00, :05… (UTC), so keep the servers' clocks in sync (NTP).exclusive=Falseruns a job in every process. - Cron: five fields (minute, hour, day of the month, month, day of the week, Sunday being 0 or
7) with
*, lists, ranges, steps and names (*/15 9-17 * * mon-fri), or@hourly,@daily,@weekly,@monthlyand@yearly, in the time zonetz(UTC by default). When the clocks change it does what cronie does: a job at a fixed time runs an hour later on the day its hour is skipped and once on the day it repeats; a job with*in its minute or hour follows real time. No dependency: the parser is Alcazaba's. - No drift, no bursts: the next run is the next tick after the clock, not a delay after the last run ended; ticks missed while a run took longer than its interval are skipped, not run in a row afterwards.
- Failures: an exception is logged with its traceback, counted, and the job keeps its
schedule. If the backend fails, the tick is skipped (
fail_open=Trueruns it anyway, maybe in every process). - Start and stop: the jobs start with the app and never hold up its startup. At shutdown,
jobs waiting for their next run stop at once and running ones get
graceseconds (10) before they're cancelled; a sync job's thread runs to its end.scheduler.next_run(name)says when a job runs next. A job is named after its module and function (name=for another), the name that its ticks have in the backend.
Task queue¶
FastAPITaskQueue hands work over from an endpoint to run in the background, in this process or
in any other that shares the cache's Redis or Valkey:
from fastapi_alcazaba import FastAPITaskQueue
queue = FastAPITaskQueue() # FastAPICache's backend; or FastAPITaskQueue(cache)
@queue.task(retries=5)
async def send_welcome_email(user_id: int) -> None: ...
app = FastAPI(lifespan=queue.lifespan) # this process runs tasks too
@app.post("/users")
async def create_user(user: NewUser) -> None:
...
await queue.enqueue(send_welcome_email, user.id) # delay=60 or a timedelta to wait first
- At least once: tasks live in a Redis stream read by a consumer group, and leave it only once
they've finished. A process that dies while running one, or can't reach the backend to say it
finished, means that it runs again elsewhere: write tasks so that running one twice does no harm
(check first whether the e-mail went out). A worker that stops answering has its tasks taken
over after
visibility_timeout(60 s); running tasks are kept from that by heartbeats, however long they run. A task handed out more thanmax_deliveriestimes (5) goes to the dead letters instead: it may be what kills the workers. - Only registered tasks run, with JSON arguments, never pickle.
enqueuechecks that the arguments fit the function and serialise (TypeError otherwise), up tomax_payload_sizebytes (64 KiB); they arrive as JSON gives them back (tuples as lists). - Sealed: with a
signing_keyorencryption_keyon the cache, every task is signed or encrypted and bound to its queue. A task that someone planted in the backend or altered is refused, logged and sent to the dead letters, never run, and so is a copy of a sealed task written back to the queue: each delivery has a run id that only one entry may claim, untilreplay_window(7 days) after it was due, when it's refused as stale. Without a key a warning is logged at start: whoever can write to the backend can run your tasks with arguments of their choosing. - Retries: a task that raises is tried again
retriestimes (3),backoffseconds after the first failure (1), twice that after the second… up tomax_backoff(600), each with jitter so that tasks that failed together (a service down) don't all retry at once. Then it goes to the dead letters with the type of its error, never the message, which may hold personal data.dead_letters()lists them,requeue_dead_letters()puts them back with their attempts reset, anddiscard_dead_letters(ids)deletes them. - Delayed tasks and retries wait in a sorted set, and move to the stream when they're due in a Lua script: two processes never both move one.
- Bounded: the queue holds at most
max_lengthtasks (100,000: waiting, running or delayed), andenqueueraisesQueueFullbeyond, instead of dropping any; aboutmax_dead_letters(10,000) are kept.pending()counts the tasks that haven't finished. - Workers:
start()andstop(),lifespanorasync with, runningconcurrencytasks at a time (10) in each process; sync functions run in a thread.stop()takes no more tasks and gives the running onesgraceseconds (10); those still running then are cancelled and go back to the queue at once (a sync task's thread can't be stopped: it runs to its end, so that task may run twice). A process that only runs tasks:async with queue: await asyncio.Event().wait(). - Backends: Redis 6.2+ or Valkey through
RedisBackendorValkeyBackend(or aTieredBackendin front of one), clusters included: a queue's keys share a hash tag. Other backends raiseNotImplementedError. Its keys are state keys, whichclear()never deletes. - Small on purpose: no results, priorities or workflows. Each run is the root of a trace of its own, linked to the span of the request that enqueued it.
Leader election¶
FastAPILeader elects one process among all those sharing the backend, for work that must run in
exactly one of them all the time (a queue consumer, an outbox relay, a watcher) rather than at
given times (that's the scheduler):
from fastapi_alcazaba.background import FastAPILeader
leader = FastAPILeader("outbox") # FastAPICache's backend; or FastAPILeader("outbox", cache=cache)
@leader.run
async def relay_outbox() -> None:
while True:
await relay_pending()
await asyncio.sleep(1)
app = FastAPI(lifespan=leader.lifespan) # or `async with leader:` in your lifespan
...
if leader.is_leader: # anywhere, sync code included
...
- Election: every process campaigns for a lock named after the leader, renewed every
ttl / 3seconds (ttlis 15 by default). The one that takes it leads and runs the functions given torun; the others try again everyttl / 3seconds or so, with jitter. - Hand-over: a leader that stops releases the lock, and another process takes over within
ttl / 3seconds; one that dies, withinttland a third. A leader that loses the lock (its renewals couldn't keep up) cancels its functions within a second and campaigns again; at the next election they run again. - Functions: async, without arguments, added before
start(). They're cancelled when the leadership ends and must let the cancellation through. An exception is logged with its traceback; the process keeps leading. - Failures: while the backend is down nobody is elected, and a leader that can't renew steps down when its lock expires: no leader is better than two. memcached can't hold locks.
- Limits: a leader that stalls for longer than
ttl(a long pause, a network partition) can overlap the next one for a moment. Checkis_leaderbetween the steps of long work, and keep that work idempotent.
Locks¶
A lock shared by every process and container, for work that only one of them must do at a time:
from fastapi_alcazaba.background import LockNotAcquired
async def nightly_report() -> None: # every instance runs it; one builds the report
try:
async with FastAPICache.lock("nightly-report", ttl=60): # or cache.lock(...)
await build_report()
except LockNotAcquired:
pass # another instance is on it
- Expiry and renewal: a lock expires after
ttlseconds, so a holder that dies doesn't keep it forever; while it's held, a background task renews it everyttl / 3. - Only the holder releases or renews it: each acquisition has a random token, checked by a Lua
script on Redis, Valkey and GLIDE and by conditional writes on DynamoDB (in memory, within one
process). memcached can't hold locks. On DynamoDB, expiry compares the clocks of the app's
servers: keep them in sync (NTP). A custom backend written for 0.1 has no
extend_lock: userenew=Falsethere, with attllonger than the work. - Waiting:
wait=0(the default) tries once,wait=5keeps trying for 5 seconds,wait=Noneas long as it takes.acquire()andrelease()work withoutasync with. - Failures: if the backend fails,
acquire()raisesBackendUnavailable: without it nobody can tell who holds the lock. - Limits: a holder that stalls for longer than
ttl(a long pause, a network partition) loses the lock while it still runs.lock.heldturns False when the renewals couldn't keep up: check it between the steps of long jobs, and make work that must never overlap idempotent too.
Broadcast¶
Experimental
New in 0.7: its API may still change in any release.
What one process must tell the WebSockets of the others (a chat, a live board, notifications), or
the other processes themselves (a setting changed): FastAPIBroadcast, over Redis or Valkey's
pub/sub.
from fastapi_alcazaba import FastAPIBroadcast
broadcast = FastAPIBroadcast(redis_client, signing_key=settings.broadcast_key)
@app.post("/rooms/{room}/messages")
async def say(room: int, form: Say, user: CurrentUser) -> None:
await broadcast.publish(f"room:{room}", {"from": user.name, "text": form.text})
@app.websocket("/rooms/{room}")
async def listen(websocket: WebSocket, room: int) -> None:
await websocket.accept()
async with broadcast.subscribe(f"room:{room}") as messages:
async for message in messages:
if message.gap:
await websocket.send_json(await room_state(room)) # some may have been missed
else:
await websocket.send_json(message.data)
- At most once: a message reaches those subscribed when it's published. One that a
subscriber missed (its connection to Redis dropped, or it fell
queue_sizemessages behind) doesn't come back: the subscriber gets agapinstead, and sends the state again. Work that must happen goes to the task queue or the outbox. - One subscription per process, whatever its number of subscribers, which reconnects by itself, with a backoff.
- Signed: with
signing_key(32 bytes or more, the same in every process; a list rotates it), each message carries an HMAC of its channel and data, and those without it, planted by whoever can write to Redis, are dropped. - Bounded: a message is JSON, 64 KiB at most; channels are letters, digits and
_.:-. - Without a client, messages stay in the process: for tests, and apps of one process.
- At shutdown,
await broadcast.close()in the lifespan.
Outbox¶
Experimental
New in 0.7: its API may still change in any release.
A change saved and the message that announces it (to the task queue, a webhook, another service)
go together or not at all: the message is written in the same database transaction as the change,
and a relay delivers it afterwards, at least once. With the sqlalchemy extra.
from fastapi_alcazaba.background import FastAPILeader
from fastapi_alcazaba.background.outbox import (
OutboxMessage,
OutboxRelay,
SQLAlchemyOutbox,
outbox_table,
)
outbox = SQLAlchemyOutbox(outbox_table(Base.metadata), sessions=async_session)
@app.post("/orders")
async def place(form: Order) -> dict[str, int]:
async with async_session() as session, session.begin():
order = Order(**form.model_dump())
session.add(order)
await session.flush()
await outbox.add(session, "order.placed", {"order_id": order.id}) # same transaction
return {"id": order.id}
async def deliver(message: OutboxMessage) -> None:
await queue.enqueue(order_placed, message.id, message.payload) # skips ids it has seen
relay = OutboxRelay(outbox, deliver)
leader = FastAPILeader("outbox") # one process relays
@leader.run
async def relay_outbox() -> None:
await relay.run()
- The table is yours, in your migrations:
outbox_table(metadata)defines it (outboxby default).sessionsmakes the relay's sessions, asessionmakeror anasync_sessionmaker. - At least once:
deliver(message)is awaited, and the message marked as sent when it returns. A process that stops in between delivers it again: each message has anid, and consumers keep the ones they've seen. - Retries: when
deliverraises, the message is tried again 1, 2, 4… seconds later, up tomax_delay, and given up on aftermax_attempts: kept in the table with its last error, never sent, for someone to look at. - In order, by one relay: the oldest due messages first,
batchat a time. Run the relay in one process (leader election). - Payloads are JSON, 256 KiB at most: ids and what changed, not files.