ADR-0003 — Hangfire on PostgreSQL¶
- Status: Accepted
- Date: 2026-04-28
Context¶
Sense Worker needs:
- Recurring jobs (partman maintenance, access expiration, PMTiles generation, Citylens migration trigger).
- One-off background jobs (manually triggered or operation-emitted).
- A dashboard for ops to see what's running and re-trigger failed jobs.
- Operability — restart-safe, retry on failure, distributed-safe (multiple Worker pods don't double-execute the same job).
Options considered:
- Hangfire on Postgres — battle-tested .NET scheduler; uses Postgres for state.
- Quartz.NET on Postgres / SQL Server / Redis — capable but heavier ergonomics; less of a turn-key dashboard.
- Temporal — the right answer for complex workflows with branching state. Overkill for our cron-style jobs and adds another stateful service to operate.
- Kubernetes CronJobs — works for purely cron-driven recurring jobs, but doesn't handle one-offs from the application code or give us a dashboard.
Decision¶
Use Hangfire 1.8 with Hangfire.PostgreSql 1.21, backed by a separate Postgres database (axion_sense_tasks) on the same Postgres cluster as the main DB.
Consequences¶
- Positive: Zero new infrastructure. Postgres is already there.
- Positive: Hangfire Dashboard (gated by OIDC) gives ops a one-stop view of recurring + one-off jobs and a "retrigger" button.
- Positive: Distributed by default — multiple Worker pods can run; Hangfire claims jobs atomically.
- Positive: Job state survives Worker restarts.
- Negative: Postgres takes the load of Hangfire's polling + state churn. Mitigated by isolating it in its own DB so backups, monitoring, and grants are scoped separately.
- Negative: Hangfire's data model is a bit chatty (high write rate to a small set of tables). Run
VACUUMregularly; the Hangfire.PostgreSql package handles partitioning of the queue tables. - Negative: Hangfire is not a workflow engine. If a job grows into a multi-step workflow with state and branching, that's the moment to revisit Temporal.
Notes¶
The axion_sense_tasks DB also holds worker-only domain tables (CitylensTrackMapping, the migration completion flag). Keeping all "worker-facing state" in one DB simplifies the ownership model — the Worker process is the only writer.