Guide 16
Job locks and rate limits (FastAPI + workers)
Audience: Teams that need exclusive processing, API abuse protection, and fair use of third-party APIs from workers. Stack: FastAPI API + Celery/Taskiq/Dramatiq workers + RabbitMQ (jobs) + Redis as lock/rate-limit store (not the job broker).
TL;DR
| Need | Tool |
|---|---|
Only one worker runs work for key X | Distributed lock (Redis/DB) or unique job key |
| Protect HTTP endpoints | API rate limit (gateway or app middleware) |
| Limit how fast jobs hit a vendor API | Job rate limit (token bucket / Celery rate / middleware) |
| “Latest wins” for noisy updates | Debounce + lock (see also unique jobs) |
API rate limit ──► enqueue (maybe) ──► RabbitMQ
│
job rate limit + lock ──► side effectRedis here is for locks and counters. Durable job transport stays on RabbitMQ.
Contents
- When you need a lock
- Lock patterns
- Unique jobs vs locks
- API rate limiting
- Job rate limiting
- Pseudocode catalog
- Failure modes
- Metrics and alerts
- Checklist
- Anti-patterns
---
1. When you need a lock
| Situation | Without lock | With lock |
|---|---|---|
Update credit score for user_id | Concurrent overwrites | One job at a time per user |
Reindex product sku | Duplicate full reindexes | Skip or wait |
Ledger post for account_id | Double post risk | Serialize per account |
Multi-step import for import_id | Parallel steps corrupt state | Exclusive run |
Do not lock the entire global queue. Lock the smallest key that protects the invariant (user_id, order_id, tenant_id+resource).
---
2. Lock patterns
A. Redis atomic lock (common)
# PSEUDOCODE : SET key value NX EX ttl
async def acquire_lock(redis, key: str, token: str, ttl_sec: int) -> bool:
return await redis.set(key, token, nx=True, ex=ttl_sec)
async def release_lock(redis, key: str, token: str) -> None:
# Lua: delete only if value == token (avoid deleting someone else's lock)
await redis.eval(RELEASE_LUA, keys=[key], args=[token])# PSEUDOCODE : worker
@celery_app.task(bind=True, max_retries=10)
def update_score(self, job_id: str, user_id: str):
token = new_uuid()
key = f"lock:score:{user_id}"
if not acquire_lock(redis, key, token, ttl_sec=120):
# another worker holds it : retry later (job rate / overlap)
raise self.retry(countdown=15)
try:
mark_running(job_id)
do_update(user_id)
mark_succeeded(job_id)
finally:
release_lock(redis, key, token)TTL must exceed worst-case work time, or use renewal (watchdog) for long jobs.
B. Database lock / advisory lock
-- PSEUDOCODE : Postgres advisory lock
SELECT pg_try_advisory_lock(hashtext(:user_id));Good when Redis is unavailable and DB is already the system of record. Prefer short critical sections.
C. “Claim” row in jobs table
-- PSEUDOCODE
UPDATE jobs
SET status = 'running', locked_by = :worker, locked_at = now()
WHERE id = :id AND status = 'pending'
RETURNING *;Serializes that job; does not by itself prevent two different jobs for the same user_id. Combine with a uniqueness key or entity lock.
D. WithoutOverlapping (Laravel-style)
Same as Redis lock keyed by entity. Release or retry with delay when not acquired (see 13 Laravel map).
---
3. Unique jobs vs locks
| Mechanism | Prevents | Does not prevent |
|---|---|---|
| Unique / idempotency key at enqueue | Two identical jobs on the queue | Two different job types on same entity |
| Lock while running | Concurrent execution for a key | Duplicate enqueues (unless also unique) |
| Both | Best for “one reindex per product” | : |
# PSEUDOCODE : unique enqueue
key = f"reindex:{product_id}"
job = await db.insert_job_if_absent(idempotency_key=key, ...)
if job.already_existed:
return job # 202 with existing id
await bus.publish(...)# PSEUDOCODE : lock while running (even if two slipped through)
lock_key = f"lock:reindex:{product_id}"---
4. API rate limiting
Protect the HTTP edge before work is enqueued.
| Layer | Example |
|---|---|
| API gateway / reverse proxy | nginx, Envoy, cloud WAF |
| App middleware | slowapi, custom Redis limiter |
| Per-route | Stricter on /auth/login, /jobs enqueue |
# PSEUDOCODE : Redis fixed window (simple)
async def allow(redis, key: str, limit: int, window_sec: int) -> bool:
n = await redis.incr(key)
if n == 1:
await redis.expire(key, window_sec)
return n <= limit
# key examples:
# ratelimit:ip:{ip}
# ratelimit:user:{user_id}:enqueue# PSEUDOCODE : FastAPI dependency
async def rate_limit_enqueue(user=Depends(auth), redis=Depends(get_redis)):
ok = await allow(redis, f"rl:enqueue:{user.id}", limit=30, window_sec=60)
if not ok:
raise HTTPException(429, "Too many job submissions")Multi-worker API: shared Redis (or gateway) so limits are global, not per process.
Response: 429 + Retry-After when possible.
---
5. Job rate limiting
Workers must not stampede third parties (email, SMS, payment, OpenAI, etc.).
Strategies
| Strategy | How |
|---|---|
| Token bucket / leaky bucket in Redis | Before side effect, take token or retry(countdown=…) |
| Celery `rate_limit` | e.g. 10/m on the task decorator |
| Dedicated slow queue + low concurrency | jobs.io with -c 2 |
| Prefetch = 1 | Fairness for long tasks |
| Per-tenant keys | rl:vendor:tenant:{id} so one tenant cannot starve others |
# PSEUDOCODE : Celery built-in (process-local-ish; prefer Redis for multi-worker fairness)
@celery_app.task(rate_limit="30/m")
def send_email(job_id: str):
...# PSEUDOCODE : Redis token bucket shared across workers
async def take_token(redis, key: str, rate_per_sec: float, burst: int) -> bool:
# classic token bucket Lua or redis-cell / redis-py bucket
...
@celery_app.task(bind=True, max_retries=20)
def call_vendor(self, job_id: str, tenant_id: str):
if not take_token(redis, f"rl:vendor:{tenant_id}", rate_per_sec=5, burst=10):
raise self.retry(countdown=2)
do_http_call(...)Enqueue vs execute limits
| Limit | Protects |
|---|---|
| API enqueue RL | Your DB/broker from floods |
| Job execute RL | Downstream vendors |
| Both | Production default for public “start job” APIs |
---
6. Pseudocode catalog
Lock + rate limit together
# PSEUDOCODE
@celery_app.task(bind=True, max_retries=25)
def sync_account(self, job_id: str, account_id: str):
if not take_token(redis, "rl:sync-global", rate_per_sec=20, burst=40):
raise self.retry(countdown=1)
token = new_uuid()
lock_key = f"lock:account:{account_id}"
if not acquire_lock(redis, lock_key, token, ttl_sec=300):
raise self.retry(countdown=10)
try:
if job_already_succeeded(job_id):
return
mark_running(job_id)
run_sync(account_id)
mark_succeeded(job_id)
except TransientError as e:
mark_retry(job_id, str(e))
raise self.retry(countdown=backoff(self.request.retries))
except PermanentError as e:
mark_failed(job_id, str(e))
finally:
release_lock(redis, lock_key, token)Safe lock release (Lua sketch)
-- RELEASE_LUA
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endLock renewal for long jobs
# PSEUDOCODE
async def with_lock(key, ttl=60):
token = new_uuid()
if not acquire(key, token, ttl):
raise LockBusy()
renew = asyncio.create_task(renew_loop(key, token, ttl))
try:
yield
finally:
renew.cancel()
release(key, token)---
7. Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
| Lock TTL too short | Two workers in critical section | TTL > max runtime or renew |
| Lock TTL too long + crash | Key stuck, work stalls | TTL bound; fencing token; admin unlock runbook |
| Retry without backoff on lock busy | Hot loop | countdown + jitter |
| API RL only in-memory | Ineffective with N API pods | Shared Redis / gateway |
Celery rate_limit only | Uneven multi-worker | Redis bucket for global fairness |
| Lock key too coarse | Throughput collapse | Narrower keys |
| Lock key missing tenant | Cross-tenant blocking | Include tenant_id in key |
---
8. Metrics and alerts
| Metric | Use |
|---|---|
lock_acquire_fail_total | Contention |
lock_hold_seconds | TTL tuning |
rate_limit_requeue_total | Job RL pressure |
api_429_total | Client abuse / limits too tight |
job_time_to_start | Locks/RL delaying start |
Alert when lock fail rate or RL requeues spike with backlog growth (possible under-capacity or deadlocks).
---
9. Checklist
Locks
- [ ] Identify resources that need exclusive access (document keys)
- [ ] Use smallest lock key that protects the invariant
- [ ] Redis/DB lock with token-safe release
- [ ] TTL ≥ work time or active renewal
- [ ] On lock busy: retry with delay, not busy-spin
- [ ] Combine with idempotent handlers
- [ ] Unique/idempotency key at enqueue where duplicates are useless
API rate limits
- [ ] Global and per-IP (and per-user) limits on public APIs
- [ ] Stricter limits on auth and enqueue endpoints
- [ ] Shared store across API replicas
- [ ]
429+Retry-Afterwhere applicable
Job rate limits
- [ ] Per-vendor / per-tenant limits for outbound calls
- [ ] Shared limiter across all workers (Redis)
- [ ] Low-concurrency queues for heavy/IO-bound classes
- [ ] Prefetch tuned so rate limits are not bypassed by huge in-flight batches
Ops
- [ ] Metrics for lock contention and RL requeues
- [ ] Runbook: clear stuck lock (with fencing discipline)
- [ ] Redis HA if locks/RL depend on it; app degrades safely if Redis down (fail closed on enqueue RL)
---
10. Anti-patterns
| Avoid | Prefer |
|---|---|
Global lock:all-jobs | Per-entity keys |
| Infinite wait for lock | Bounded retries → fail/DLQ |
| In-process locks only | Distributed locks for multi-worker |
| Rate limit only at API, workers blast vendor | Job RL too |
| Using RabbitMQ as a lock service | Redis/DB locks |
| Storing lock state only in memory of one worker | Shared store |
---
Celery implementation
Step-by-step with FastAPI + Celery + Redis helpers: [17 Celery RL + lock pipeline](17-celery-rate-limit-lock-pipeline.md)
See also
- 09 Errors / retries / DLQ
- 03 Job lifecycle
- 13 Laravel queues map (WithoutOverlapping, RateLimited)
- 15 Observability & Flower
- 05 Celery example
External concepts
- Redis
SET NX EX/ Redlock (know the tradeoffs) - Token bucket rate limiting
- Postgres advisory locks
- Celery rate limits