All notable changes to RustQueue are documented here.
Format loosely follows Keep a Changelog.
- Telegram job handler (second executor type)
- Scheduled jobs via API (
run_atfield) - Typed
JobStatusenum (separate branch:feature/job-status-enum) - Cursor-based pagination on
GET /jobs - Graceful shutdown on Ctrl+C
- Cloudflare Email Service exploration as alternative executor
LISTEN/NOTIFYtrigger on job insert -- worker wakes instantly instead of polling every 2 seconds- Second trigger on
UPDATEfor retry-back-to-pending transitions -- retried jobs also wake the worker promptly - Fallback 2-second timeout on the worker loop as a safety net for missed notifications
processing_started_atcolumn -- dedicated timestamp for stale-job detection, replacing the noisyupdated_atsignal- Stale-job reaper now clears
processing_started_aton reclaim
- Worker loop rebuilt around
PgListenerinstead oftokio::time::sleep - SMTP transport built once at startup via
HandlerRegistryand shared viaArc-- no longer rebuilt per job execution run_workernow takesArc<HandlerRegistry>instead ofConfigdirectlyHandlerRegistryintroduced as a central map of job type strings to pre-built handlers
- Race condition: stale-job reaper could incorrectly reclaim jobs that were legitimately mid-retry-backoff (not actually stuck). Fixed by introducing
processing_started_at-- the reaper now only looks at this dedicated column, not the general-purposeupdated_at.
- Concurrent job processing via
tokio::spawnper job, capped at 10 simultaneous jobs viaSemaphore - Exponential backoff retry logic: failed jobs retry at 1s, 5s, 25s... up to
max_attempts(default 3) - Stale-job reaper: background task reclaims jobs stuck in
processingfor more than 5 minutes attemptscolumn now correctly incremented on every execution attempt (success or failure)processing_started_atcolumn added tojobstable
mark_doneandmark_failednow also updateattemptsand clearprocessing_started_at- Worker loop drains all pending jobs in a tight inner loop before sleeping
attemptswas previously only incremented on failure, not on success -- a successful first attempt would showattempts: 0incorrectly
- Initial working implementation
POST /jobs-- submit a jobGET /jobs-- list jobs with optional?limit=Nand?status=filters (default limit: 20, max: 100)GET /jobs/:id-- get a specific jobGET /events-- SSE stream of live job status updates- PostgreSQL-backed job persistence via sqlx
SELECT FOR UPDATE SKIP LOCKEDjob claiming -- race-condition-safe, multiple workers can run without double-processing- Email job handler via AWS SES (lettre crate, STARTTLS on port 587)
broadcast::channelpowering SSE fanout to multiple connected clientsArc<AppState>shared between Axum handlers and worker loop- Basic retry on failure (later evolved into full exponential backoff in 0.2.0)
- Migrations tracked via sqlx-cli
Why SELECT FOR UPDATE SKIP LOCKED instead of application-level locking?
Database-level locking is simpler, more reliable, and works correctly even with multiple worker processes or instances. Application-level locks (Redis, in-memory mutexes) add a dependency and a failure mode; Postgres already has the data, so it should own the lock too.
Why a dedicated processing_started_at column instead of using updated_at?
updated_at gets touched by retry scheduling, status transitions, and anything else that modifies a job row. Using it to detect "stuck in processing" led to a real race condition where legitimately-retrying jobs (waiting on backoff delay) were incorrectly flagged as stale and reclaimed. processing_started_at is set ONLY when a job enters processing and cleared on every exit -- its meaning is unambiguous.
Why HandlerRegistry instead of building handlers in get_handler per job?
Building a new SMTP transport per job meant a full TLS handshake and authentication round-trip on every single email. Pre-building handlers at startup and sharing them via Arc eliminates this overhead entirely. The registry pattern also makes adding new job types a one-line registration, not a scattered conditional.