Serverless-friendly dispatch: tick() endpoint for zero-infrastructure deployments
- Dominant language
- Rust
- Stars
- 32
- Forks
- 5
- Avg merge
- 15h 21m
- Merged PRs (30d)
- 21
Description
## Context
Awa currently requires an always-on worker process for dispatch and maintenance. This works well for most deployments, but there's a class of very low-traffic projects (e.g., small Supabase apps, hobby projects) where running a persistent process is overkill. These projects might see only a handful of jobs per day, have relaxed latency requirements (minutes, not milliseconds), and want zero additional infrastructure beyond Postgres.
With the `HttpWorker` (ADR-018), the *execution* side is already serverless — jobs are POSTed to Lambda/Cloud Run/edge functions. But the *dispatch and maintenance* side still needs a persistent process to:
1. Promote scheduled/retryable jobs → available
2. Claim available jobs and POST them to function URLs
3. Rescue stale heartbeats (crashed functions)
4. Rescue timed-out callbacks
5. Run leader election and cleanup
## Exploration
### Piggyback dispatch
For apps already serving HTTP traffic (FastAPI, Starlette, Django), dispatch could run as a background task after each request:
```python
@app.post("/orders")
async def create_order(order: Order, background_tasks: BackgroundTasks):
await client.insert(ProcessOrder(order_id=order.id))
background_tasks.add_task(client.tick) # dispatch + maintenance
return {"status": "created"}
```
This works for dispatch — the cost per request is small (one SKIP LOCKED query + an HTTP POST), and since traffic is low, there's no contention concern.
### The maintenance gap problem
The piggyback pattern breaks down when there's no traffic. If no requests arrive for 3 days:
- A crashed function's job sits in `running` with a stale heartbeat — never rescued
- Callback timeouts never fire — timed-out jobs stay in `waiting_external`
- Scheduled jobs never promote to `available`
The system is frozen until the next request arrives.
### Cheap external heartbeat
The fix is a single scheduled HTTP call:
```
POST /api/tick → promote + rescue + dispatch (all in one bounded call)
```
Driven by:
- Cloud Scheduler / EventBridge rule (every 1-5 min, pennies/month)
- `pg_cron` (Supabase has this built-in)
- Vercel/Netlify cron
- Even a GitHub Actions scheduled workflow
This covers the maintenance gap without requiring a persistent process.
## Proposed design
### `tick()` function
A single bounded function that combines all maintenance and dispatch work:
```rust
pub async fn tick(pool: &PgPool, http_workers: &[HttpWorkerConfig]) -> TickResult {
// 1. Promote scheduled → available (bounded batch)
// 2. Promote retryable → available (bounded batch)
// 3. Rescue stale heartbeats (bounded batch)
// 4. Rescue timed-out callbacks (bounded batch)
// 5. Claim and dispatch available jobs via HTTP (bounded batch)
// 6. Cleanup completed/failed jobs (bounded batch)
}
```
Each step is bounded (e.g., LIMIT 100) so the function completes in predictable time.
### `POST /api/tick` endpoint
Added to `awa-ui` (or standalone). Accepts an optional auth token. Returns a summary of work done.
### Python API
```python
# As a background task
await client.tick()
# Or from a scheduled function
@scheduled(every="5m")
async def maintenance():
client = awa.AsyncClient(DATABASE_URL)
await client.tick()
```
## What this doesn't solve
- **Sub-second dispatch latency.** The tick interval is the lower bound. For real-time dispatch, use the persistent worker.
- **LISTEN/NOTIFY wakeup.** No persistent PG connection means no instant notification. Polling interval is the tick interval.
- **True scale-to-zero for the dispatcher.** You still need *something* calling tick() — it's just much cheaper than a persistent process.
## Open questions
- Should `tick()` be a method on `Client`, a standalone function, or both?
- Should the `/api/tick` endpoint require auth by default?
- How to handle leader election without persistent advisory locks? (Probably: skip it, let SKIP LOCKED handle contention between concurrent ticks)
- Should there be a `pg_cron` SQL function that calls tick directly, avoiding the HTTP layer entirely?
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.