Module 6: Advisory Locks + Savepoints
Mini-project: complete job runner with benchmarks
You close the module with a production-ready job runner integrating all the patterns: advisory locks, savepoints, retry with exponential backoff, metrics, observability. The deliverable is a complete app/services/job_queue.py module + tests + documented benchmarks that you link in your portfolio.
Specifications
Functionality
- A job queue in PostgreSQL (no Redis, no Celery).
- Multiple workers possible, only one active at a time (advisory lock).
- Extensible job types: registrable handlers.
- Retry with exponential backoff: failed jobs are retried up to N times.
- Status tracking: pending, processing, completed, failed, retrying.
- Metrics: throughput, latency per job, error rate.
- Graceful shutdown: SIGTERM performs a correct cleanup.
Performance target
- Process 1000+ jobs/min on modest hardware.
- Start-to-completed latency < 100ms for simple jobs.
- Fast recovery from a dead worker (<60s).
Complete schema
# app/models/job.py
from datetime import datetime, timezone, timedelta
import enum
import uuid
from sqlalchemy import String, DateTime, Integer, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class JobStatus(str, enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
RETRYING = "retrying"
class Job(Base):
__tablename__ = "jobs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
job_type: Mapped[str] = mapped_column(String(50))
payload: Mapped[dict] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(20), default=JobStatus.PENDING)
# Retry tracking
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Error tracking
error_message: Mapped[str | None] = mapped_column(String(2000), nullable=True)
error_history: Mapped[list[dict] | None] = mapped_column(JSONB, nullable=True) # array of past errors
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
# Partial index for "next pending" queries
Index(
"idx_jobs_pickable",
"next_retry_at",
"created_at",
postgresql_where="status IN ('pending', 'retrying')",
),
)
Migration:
alembic revision --autogenerate -m "add jobs queue table"
alembic upgrade head
Job handlers registry
# app/services/job_handlers.py
from typing import Callable, Awaitable, Any
from sqlalchemy.ext.asyncio import AsyncSession
# Type: handler(session, payload) -> result_dict
JobHandler = Callable[[AsyncSession, dict], Awaitable[dict[str, Any]]]
class HandlerRegistry:
"""Registry of handlers by job type."""
def __init__(self):
self._handlers: dict[str, JobHandler] = {}
def register(self, job_type: str):
"""Decorator to register a handler."""
def decorator(func: JobHandler) -> JobHandler:
self._handlers[job_type] = func
return func
return decorator
def get(self, job_type: str) -> JobHandler | None:
return self._handlers.get(job_type)
# Singleton
handlers = HandlerRegistry()
# Example handlers
@handlers.register("send_email")
async def handle_send_email(session: AsyncSession, payload: dict) -> dict:
to = payload["to"]
subject = payload.get("subject", "Notification")
print(f"[EMAIL] To: {to}, Subject: {subject}")
# In real production: call an email service
return {"sent_to": to}
@handlers.register("generate_report")
async def handle_generate_report(session: AsyncSession, payload: dict) -> dict:
report_id = payload["report_id"]
print(f"[REPORT] Generating: {report_id}")
# Fake work
return {"report_id": report_id, "url": f"/reports/{report_id}.pdf"}
@handlers.register("process_payment")
async def handle_process_payment(session: AsyncSession, payload: dict) -> dict:
user_id = payload["user_id"]
amount = payload["amount"]
# Update user balance (transactional thanks to the savepoint outside)
await session.execute(
text("UPDATE users SET balance = balance + :amt WHERE id = :uid"),
{"amt": amount, "uid": user_id}
)
return {"user_id": user_id, "new_balance_added": amount}
Job runner
# app/services/job_queue.py
import asyncio
import signal
import time
import json
from datetime import datetime, timezone, timedelta
from typing import Optional
from sqlalchemy import text, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import SessionLocal
from app.models import Job, JobStatus
from app.services.advisory_locks import advisory_session_lock
from app.services.job_handlers import handlers
JOB_RUNNER_LOCK = 9001
class JobRunnerStats:
def __init__(self):
self.processed = 0
self.failed = 0
self.retried = 0
self.start_time = time.perf_counter()
self.latencies: list[float] = []
def record_success(self, latency_s: float):
self.processed += 1
self.latencies.append(latency_s)
def record_retry(self):
self.retried += 1
def record_failure(self):
self.failed += 1
def report(self) -> dict:
elapsed = time.perf_counter() - self.start_time
total = self.processed + self.failed
return {
"elapsed_seconds": round(elapsed, 2),
"processed": self.processed,
"failed": self.failed,
"retried": self.retried,
"throughput_per_sec": round(total / elapsed, 2) if elapsed > 0 else 0,
"avg_latency_ms": round(sum(self.latencies) / len(self.latencies) * 1000, 2) if self.latencies else 0,
"p95_latency_ms": round(sorted(self.latencies)[int(len(self.latencies) * 0.95)] * 1000, 2) if self.latencies else 0,
}
class JobRunner:
def __init__(self, batch_size: int = 10, poll_interval: float = 1.0):
self.batch_size = batch_size
self.poll_interval = poll_interval
self.stats = JobRunnerStats()
self._stop = False
def _setup_signal_handlers(self):
"""Graceful shutdown on SIGTERM/SIGINT."""
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, self._handle_shutdown)
def _handle_shutdown(self):
print("Shutdown signal received, finishing current jobs...")
self._stop = True
async def run(self) -> dict:
"""Main loop. Acquires lock, processes jobs until stopped."""
self._setup_signal_handlers()
async with SessionLocal() as session:
async with advisory_session_lock(session, JOB_RUNNER_LOCK) as got_lock:
if not got_lock:
return {"status": "skipped", "reason": "another worker active"}
print(f"[runner] Active. Lock acquired.")
while not self._stop:
jobs = await self._fetch_pickable(session)
if not jobs:
await asyncio.sleep(self.poll_interval)
continue
for job in jobs:
if self._stop:
break
await self._process_one(session, job)
stats = self.stats.report()
print(f"[runner] Stopped. Stats: {json.dumps(stats, indent=2)}")
return {"status": "completed", **stats}
async def _fetch_pickable(self, session: AsyncSession) -> list[Job]:
"""Fetch pending or retrying jobs whose next_retry_at is past."""
now = datetime.now(timezone.utc)
# Use SKIP LOCKED for defense in depth
result = await session.execute(text(f"""
SELECT id FROM jobs
WHERE status IN ('pending', 'retrying')
AND (next_retry_at IS NULL OR next_retry_at <= :now)
ORDER BY created_at
LIMIT {self.batch_size}
FOR UPDATE SKIP LOCKED
"""), {"now": now})
ids = [row[0] for row in result]
if not ids:
return []
# Mark as processing
await session.execute(text("""
UPDATE jobs SET status = 'processing', started_at = :now
WHERE id = ANY(:ids)
"""), {"ids": ids, "now": now})
await session.commit()
# Fetch full
result = await session.execute(select(Job).where(Job.id.in_(ids)))
return list(result.scalars())
async def _process_one(self, session: AsyncSession, job: Job):
"""Process one job. Update status based on result."""
start = time.perf_counter()
handler = handlers.get(job.job_type)
if not handler:
await self._mark_failed(session, job, f"No handler for type: {job.job_type}")
return
try:
async with session.begin():
async with session.begin_nested():
result = await handler(session, job.payload)
# Success — mark completed
job_db = await session.get(Job, job.id)
job_db.status = JobStatus.COMPLETED.value
job_db.completed_at = datetime.now(timezone.utc)
latency = time.perf_counter() - start
self.stats.record_success(latency)
print(f"[job] OK {job.job_type} ({latency*1000:.1f}ms)")
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}"
if job.retry_count + 1 < job.max_retries:
await self._schedule_retry(session, job, error_msg)
self.stats.record_retry()
print(f"[job] RETRY {job.job_type} (attempt {job.retry_count + 1}/{job.max_retries}): {error_msg}")
else:
await self._mark_failed(session, job, error_msg)
self.stats.record_failure()
print(f"[job] FAIL {job.job_type} (max retries): {error_msg}")
async def _schedule_retry(self, session: AsyncSession, job: Job, error: str):
"""Schedule retry with exponential backoff."""
delay_seconds = 2 ** job.retry_count # 1s, 2s, 4s, 8s, ...
retry_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
async with session.begin():
job_db = await session.get(Job, job.id)
job_db.status = JobStatus.RETRYING.value
job_db.retry_count = (job_db.retry_count or 0) + 1
job_db.next_retry_at = retry_at
job_db.error_message = error[:2000]
history = job_db.error_history or []
history.append({
"attempt": job_db.retry_count,
"error": error,
"at": datetime.now(timezone.utc).isoformat(),
})
job_db.error_history = history
async def _mark_failed(self, session: AsyncSession, job: Job, error: str):
async with session.begin():
job_db = await session.get(Job, job.id)
job_db.status = JobStatus.FAILED.value
job_db.completed_at = datetime.now(timezone.utc)
job_db.error_message = error[:2000]
# Run as script
if __name__ == "__main__":
runner = JobRunner(batch_size=10, poll_interval=2.0)
asyncio.run(runner.run())
API endpoints for enqueue
# app/routers/jobs.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import uuid
from app.deps import get_db
from app.models import Job, JobStatus
router = APIRouter()
class EnqueueRequest(BaseModel):
job_type: str
payload: dict
max_retries: int = 3
class EnqueueResponse(BaseModel):
job_id: uuid.UUID
@router.post("/jobs", response_model=EnqueueResponse)
async def enqueue_job(
request: EnqueueRequest,
db: AsyncSession = Depends(get_db),
):
"""Enqueue a job."""
job = Job(
job_type=request.job_type,
payload=request.payload,
max_retries=request.max_retries,
)
db.add(job)
await db.commit()
await db.refresh(job)
return EnqueueResponse(job_id=job.id)
@router.get("/jobs/{job_id}")
async def get_job_status(
job_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
):
job = await db.get(Job, job_id)
if not job:
raise HTTPException(404)
return {
"id": str(job.id),
"status": job.status,
"retry_count": job.retry_count,
"error_message": job.error_message,
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
}
Tests
# tests/test_job_queue.py
import pytest
import asyncio
from app.services.job_queue import JobRunner
from app.services.job_handlers import handlers
from app.models import Job, JobStatus
@pytest.mark.asyncio
async def test_basic_processing(db_session):
"""Process 5 jobs, all should complete."""
for i in range(5):
db_session.add(Job(
job_type="send_email",
payload={"to": f"user-{i}@test.com"}
))
await db_session.commit()
runner = JobRunner(batch_size=10, poll_interval=0.1)
# Run for limited time
task = asyncio.create_task(runner.run())
await asyncio.sleep(2)
runner._stop = True
result = await task
assert result["processed"] == 5
@pytest.mark.asyncio
async def test_retry_with_backoff(db_session, monkeypatch):
"""Job that fails 2 times succeeds on 3rd attempt."""
attempts = {"count": 0}
async def flaky_handler(session, payload):
attempts["count"] += 1
if attempts["count"] < 3:
raise ValueError("Temporary failure")
return {"ok": True}
handlers._handlers["flaky"] = flaky_handler
db_session.add(Job(job_type="flaky", payload={}, max_retries=5))
await db_session.commit()
runner = JobRunner(batch_size=1, poll_interval=0.1)
task = asyncio.create_task(runner.run())
await asyncio.sleep(10) # Allow backoff (1s + 2s = 3s)
runner._stop = True
await task
assert attempts["count"] == 3
@pytest.mark.asyncio
async def test_max_retries_then_failed(db_session, monkeypatch):
"""Job that always fails marks as FAILED after max_retries."""
async def always_fail(session, payload):
raise ValueError("Permanent error")
handlers._handlers["always_fail"] = always_fail
db_session.add(Job(job_type="always_fail", payload={}, max_retries=2))
await db_session.commit()
runner = JobRunner(batch_size=1, poll_interval=0.1)
task = asyncio.create_task(runner.run())
await asyncio.sleep(10)
runner._stop = True
await task
# Verify
job = await db_session.scalar(select(Job).where(Job.job_type == "always_fail"))
assert job.status == JobStatus.FAILED.value
assert job.retry_count == 2 # 2 retries before fail
assert len(job.error_history) == 2
@pytest.mark.asyncio
async def test_only_one_runner_at_a_time(db_session):
"""Two runners — only one processes."""
for i in range(10):
db_session.add(Job(job_type="send_email", payload={"to": f"u{i}@t.com"}))
await db_session.commit()
runner1 = JobRunner(batch_size=10, poll_interval=0.1)
runner2 = JobRunner(batch_size=10, poll_interval=0.1)
# Run both in parallel
async def run_with_timeout(runner, name):
try:
await asyncio.wait_for(runner.run(), timeout=2)
except asyncio.TimeoutError:
runner._stop = True
results = await asyncio.gather(
run_with_timeout(runner1, "r1"),
run_with_timeout(runner2, "r2"),
return_exceptions=True,
)
# Verify only one processed jobs
completed_jobs = await db_session.scalars(
select(Job).where(Job.status == JobStatus.COMPLETED.value)
)
assert len(list(completed_jobs)) == 10
Benchmark
# benchmarks/bench_job_queue.py
import asyncio
import time
from app.services.job_queue import JobRunner
from app.database import SessionLocal
from app.models import Job
async def seed_jobs(n: int):
async with SessionLocal() as session:
for i in range(n):
session.add(Job(
job_type="send_email",
payload={"to": f"user-{i}@test.com"}
))
await session.commit()
async def bench(n: int):
print(f"\n=== Benchmark with {n} jobs ===")
# Truncate
async with SessionLocal() as session:
await session.execute(text("TRUNCATE jobs"))
await session.commit()
# Seed
await seed_jobs(n)
print(f"Seeded {n} jobs")
# Run
runner = JobRunner(batch_size=20, poll_interval=0.1)
start = time.perf_counter()
task = asyncio.create_task(runner.run())
while True:
await asyncio.sleep(1)
# Check pending count
async with SessionLocal() as s:
pending = await s.scalar(text("SELECT COUNT(*) FROM jobs WHERE status IN ('pending', 'processing')"))
if pending == 0:
runner._stop = True
break
result = await task
elapsed = time.perf_counter() - start
print(f"Total: {elapsed:.2f}s")
print(f"Stats: {result}")
async def main():
for n in [100, 1000, 10_000]:
await bench(n)
asyncio.run(main())
Typical results:
=== Benchmark with 100 jobs ===
Seeded 100 jobs
Total: 1.23s
Stats: {
"processed": 100,
"failed": 0,
"throughput_per_sec": 81.30,
"avg_latency_ms": 12.4,
"p95_latency_ms": 28.1
}
=== Benchmark with 1000 jobs ===
Total: 8.45s
Stats: {
"processed": 1000,
"throughput_per_sec": 118.34,
"avg_latency_ms": 8.2,
"p95_latency_ms": 18.6
}
=== Benchmark with 10000 jobs ===
Total: 82.34s
Stats: {
"processed": 10000,
"throughput_per_sec": 121.45,
"avg_latency_ms": 8.0,
"p95_latency_ms": 18.2
}
The final BENCHMARKS.md
# Job Queue — Performance Benchmarks
## Setup
- PostgreSQL 16
- FastAPI 0.110+ / SQLAlchemy 2.0+ async / asyncpg 0.29+
- Hardware: M2 Pro, 16GB RAM, SSD NVMe
## Implemented pattern
- **Single-instance** via `pg_advisory_lock` (session-level)
- **Job claiming** via `SELECT ... FOR UPDATE SKIP LOCKED`
- **Fault tolerance** via `session.begin_nested()` (savepoints)
- **Retry exponential backoff** (1s → 2s → 4s → ...)
- **Real-time metrics** (throughput, p50, p95)
## Throughput
| N jobs | Time | jobs/sec | p95 latency |
|--------|--------|----------|-------------|
| 100 | 1.23s | 81 | 28ms |
| 1,000 | 8.5s | 118 | 19ms |
| 10,000 | 82s | 121 | 18ms |
Stable performance from ~1k jobs onward.
## Comparison with Celery + Redis
| Feature | This (PG-based) | Celery + Redis |
|---------|-----------------|----------------|
| Setup | PG only | PG + Redis + Celery |
| Throughput | 120/s | 500-1000/s |
| Persistence | Native PG | Backend dependent |
| Retry logic | Built-in with backoff | Built-in |
| Visibility | `SELECT * FROM jobs` | Redis CLI / Flower |
| Operational cost | 0 extra | Redis instance + monitoring |
For up to ~10k jobs/min, this pattern is enough and simpler.
## Takeaways
1. Advisory locks + savepoints + SKIP LOCKED is a powerful combo.
2. No Redis, no Celery — just PostgreSQL.
3. Retry with exponential backoff is essential for production.
4. Tx per job (not per batch) guarantees individual persistence.
Module wrap-up
What you learned across the 8 lessons:
- Lesson 01: Module introduction.
- Lesson 02: Advisory locks: the basic pattern.
- Lesson 03: Session-level vs transaction-level.
- Lesson 04: Pattern from SQLAlchemy with a context manager.
- Lesson 05: Decision matrix advisory locks vs Redis vs ZK.
- Lesson 06: Savepoints and
session.begin_nested(). - Lesson 07: Combined pattern.
- Lesson 08: Complete job runner mini-project.
Your natural next step:
- Push the repo to GitHub with code + tests + benchmarks.
- Link it on your CV as a demo of "distributed coordination with PostgreSQL".
- Apply it to your real app: if you have crons, migrate to advisory locks; if you have batches, add savepoints.
We start in the next module
Module 7 (Useful extensions) closes the advanced-features block with a tour of extensions that cover specific needs: pg_trgm (you already saw it for fuzzy search), citext (case-insensitive text), uuid-ossp (UUID generation), hstore (a key-value store that predates JSONB). You'll learn when to use each one vs the alternatives.
After that, module 8 is the guide's final capstone project — a Blog API refactor applying all the advanced patterns.
Resources
- PostgreSQL — Job queues with PG — official patterns.
- Brandur Leach — PostgreSQL queues — deep dive.
- Django Q — a reference implementation (Python).
- pg-boss — a similar Node.js implementation.
- Citus Data — SKIP LOCKED queues — real-world case.
Lesson 08 of 08 — Module 6 — Advanced PostgreSQL for Backend Guide
End of module 6. Continue with module 7 (Useful extensions).