hardbyte / hardbyte/awa

Serverless-friendly dispatch: tick() endpoint for zero-infrastructure deployments

Open
#118 0 comments 0 reactions 0 assignees View on GitHub
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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.