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

  1. A job queue in PostgreSQL (no Redis, no Celery).
  2. Multiple workers possible, only one active at a time (advisory lock).
  3. Extensible job types: registrable handlers.
  4. Retry with exponential backoff: failed jobs are retried up to N times.
  5. Status tracking: pending, processing, completed, failed, retrying.
  6. Metrics: throughput, latency per job, error rate.
  7. 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:

  1. Lesson 01: Module introduction.
  2. Lesson 02: Advisory locks: the basic pattern.
  3. Lesson 03: Session-level vs transaction-level.
  4. Lesson 04: Pattern from SQLAlchemy with a context manager.
  5. Lesson 05: Decision matrix advisory locks vs Redis vs ZK.
  6. Lesson 06: Savepoints and session.begin_nested().
  7. Lesson 07: Combined pattern.
  8. 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

  1. PostgreSQL — Job queues with PG — official patterns.
  2. Brandur Leach — PostgreSQL queues — deep dive.
  3. Django Q — a reference implementation (Python).
  4. pg-boss — a similar Node.js implementation.
  5. 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).