Checklist/Docs/Job locks and rate limits (FastAPI + workers)

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

Locks & rate limits: Redis beside the worker path
APIper-user RLEnqueue202 + job_idRabbitMQCelery workerRL then lockRedistoken bucketRedisentity lock NXorder: take token → acquire lock → side effect → release
NeedTool
Only one worker runs work for key XDistributed lock (Redis/DB) or unique job key
Protect HTTP endpointsAPI rate limit (gateway or app middleware)
Limit how fast jobs hit a vendor APIJob rate limit (token bucket / Celery rate / middleware)
“Latest wins” for noisy updatesDebounce + lock (see also unique jobs)
text
API rate limit ──► enqueue (maybe) ──► RabbitMQ
 │
 job rate limit + lock ──► side effect

Redis here is for locks and counters. Durable job transport stays on RabbitMQ.

Contents

  1. When you need a lock
  2. Lock patterns
  3. Unique jobs vs locks
  4. API rate limiting
  5. Job rate limiting
  6. Pseudocode catalog
  7. Failure modes
  8. Metrics and alerts
  9. Checklist
  10. Anti-patterns

---

1. When you need a lock

SituationWithout lockWith lock
Update credit score for user_idConcurrent overwritesOne job at a time per user
Reindex product skuDuplicate full reindexesSkip or wait
Ledger post for account_idDouble post riskSerialize per account
Multi-step import for import_idParallel steps corrupt stateExclusive 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)

python
# 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])
python
# 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

sql
-- 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

sql
-- 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

MechanismPreventsDoes not prevent
Unique / idempotency key at enqueueTwo identical jobs on the queueTwo different job types on same entity
Lock while runningConcurrent execution for a keyDuplicate enqueues (unless also unique)
BothBest for “one reindex per product”:
python
# 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(...)
python
# 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.

LayerExample
API gateway / reverse proxynginx, Envoy, cloud WAF
App middlewareslowapi, custom Redis limiter
Per-routeStricter on /auth/login, /jobs enqueue
python
# 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
python
# 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

StrategyHow
Token bucket / leaky bucket in RedisBefore side effect, take token or retry(countdown=…)
Celery `rate_limit`e.g. 10/m on the task decorator
Dedicated slow queue + low concurrencyjobs.io with -c 2
Prefetch = 1Fairness for long tasks
Per-tenant keysrl:vendor:tenant:{id} so one tenant cannot starve others
python
# 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):
 ...
python
# 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

LimitProtects
API enqueue RLYour DB/broker from floods
Job execute RLDownstream vendors
BothProduction default for public “start job” APIs

---

6. Pseudocode catalog

Lock + rate limit together

python
# 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)

lua
-- RELEASE_LUA
if redis.call("get", KEYS[1]) == ARGV[1] then
 return redis.call("del", KEYS[1])
else
 return 0
end

Lock renewal for long jobs

python
# 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

FailureSymptomMitigation
Lock TTL too shortTwo workers in critical sectionTTL > max runtime or renew
Lock TTL too long + crashKey stuck, work stallsTTL bound; fencing token; admin unlock runbook
Retry without backoff on lock busyHot loopcountdown + jitter
API RL only in-memoryIneffective with N API podsShared Redis / gateway
Celery rate_limit onlyUneven multi-workerRedis bucket for global fairness
Lock key too coarseThroughput collapseNarrower keys
Lock key missing tenantCross-tenant blockingInclude tenant_id in key

---

8. Metrics and alerts

MetricUse
lock_acquire_fail_totalContention
lock_hold_secondsTTL tuning
rate_limit_requeue_totalJob RL pressure
api_429_totalClient abuse / limits too tight
job_time_to_startLocks/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-After where 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

AvoidPrefer
Global lock:all-jobsPer-entity keys
Infinite wait for lockBounded retries → fail/DLQ
In-process locks onlyDistributed locks for multi-worker
Rate limit only at API, workers blast vendorJob RL too
Using RabbitMQ as a lock serviceRedis/DB locks
Storing lock state only in memory of one workerShared 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

External concepts

  • Redis SET NX EX / Redlock (know the tradeoffs)
  • Token bucket rate limiting
  • Postgres advisory locks
  • Celery rate limits