Min 25-26 — Background Work 👻¶
Heaven has two ways to do work outside the request/response cycle, and picking the right one matters.
flowchart LR
A["Something to do<br/>in the background"] --> B{"Tied to one<br/>request?"}
B -->|"yes"| C["<b>res.defer()</b><br/>runs once, after<br/>this response is sent"]
B -->|"no — it's a loop"| D["<b>app.daemons</b><br/>runs forever on<br/>an interval"] res.defer(fn) | app.daemons = fn | |
|---|---|---|
| Runs | once, after the response | repeatedly, from startup |
| Receives | the app | the app |
| Good for | a receipt email, an audit write | cache cleanup, heartbeats, queue polling |
Deferred work¶
async def send_receipt(app):
await mailer.send(...)
async def checkout(req, res, ctx):
res.body = {'status': 'ok'}
res.defer(send_receipt) # runs after the client has its response
Must be async, and takes no arguments but the app
A sync function raises TypeError after the response has already been sent, where nothing can catch it. There's also no argument passing — capture what you need in a closure:
async def checkout(req, res, ctx):
order_id = req.data['id']
async def send(app): # closes over order_id
await mailer.send(order_id)
res.defer(send)
Deferred callbacks also do not run under the Earth test client, and the connection stays open until they finish — keep them short.
Daemons¶
A daemon is a function that takes the app and returns how many seconds to wait before running again. Return None and it runs once and stops.
async def cleanup_tokens(app):
db = app.peek('db')
await db.execute('DELETE FROM tokens WHERE expires_at < NOW()')
return 60 # again in a minute
app.daemons = cleanup_tokens
Assign app.daemons more than once to register several — it appends rather than replaces.
flowchart LR
S(["startup"]) --> R["run daemon(app)"]
R --> C{"returned a<br/>number?"}
C -->|"yes — N"| W["sleep N seconds"] --> R
C -->|"None / False"| E(["stop"]) Sync daemons are fine¶
A sync daemon runs in a thread pool executor, so it won't block the event loop:
def rebuild_report(app):
heavy_pandas_thing()
return 3600 # hourly
app.daemons = rebuild_report
Genuinely CPU-heavy work still belongs in a separate process — the GIL doesn't care that you used a thread.
Never block the loop in an async daemon
time.sleep(), a synchronous database driver, or a requests call inside an async def daemon freezes the entire server — every concurrent request included. Use await asyncio.sleep(), async drivers, or make the daemon sync so it gets a thread.
Catching a blocked loop¶
Heaven ships a watchdog that tells you when something has stalled the loop:
app = App(monitor=0.1) # warn if the loop is blocked > 100ms
WARNING:heaven.monitor:Event Loop Blocked! Lag: 0.1504s
Turn it on in development. It converts "the server feels slow sometimes" into a specific line of code.
Daemons survive mounting
A daemon registered on a child app is carried onto the parent when you mount it, and starts on the parent's lifespan. It is still handed the child, so app.peek() and app.CONFIG() inside it read the child's own state rather than the parent's. That matters because an isolated mount, the default, does not share buckets or configuration between the two.
When you need a real queue¶
Daemons run inside your web process. That means they stop when it stops, they don't retry, they aren't distributed, and with multiple workers every worker runs its own copy.
For work that must not be lost — payments, anything with retry semantics, anything that shouldn't run four times because you set --workers 4 — use Celery, Dramatiq, or ARQ. Daemons are for periodic in-process chores, not for a job queue.
Next: Signing, sessions, and secrets → Min 27-28 — Security & Sessions