A multi-tenant background job processing system (DB-backed queue) with:
- Job submission + idempotency
- Distributed-safe workers (lease + SKIP LOCKED claim)
- Retries + Dead Letter Queue (DLQ)
- Per-tenant quotas + rate limiting (Redis)
- Live dashboard updates via WebSocket (STOMP)
- Simple React (Vite) admin dashboard
- Docker Compose to run everything
Backend
- Java 17 + Spring Boot
- Spring Web (REST APIs)
- Spring Data JPA + Flyway
- Postgres (jobs storage + queue)
- Redis (rate limit counters)
- Spring WebSocket (STOMP)
- Spring Actuator + Micrometer (metrics + health)
Frontend
- React + Vite
- STOMP WebSocket client (
@stomp/stompjs)
Infra
- Docker Compose (Postgres + Redis + Backend + Frontend)
- Job states:
PENDING → RUNNING → DONE - Failure path:
RUNNING → PENDING (retry)and eventuallyDLQ
- Exactly-one worker claim using Postgres:
SELECT ... FOR UPDATE SKIP LOCKED - Lease mechanism:
- worker sets
lease_until = now + 30s - expired lease jobs become reclaimable
- worker sets
- Tenant identified using request header:
X-Tenant-Id: user1 - Jobs are isolated per tenant in API + UI
- Concurrency limit: max 5 RUNNING jobs per tenant
- Rate limiting: max 10 job submissions / minute per tenant (Redis)
- After
maxAttemptsfailures, job goes toDLQ - DLQ jobs are visible in dashboard
- WebSocket topics per tenant:
/topic/tenant/{tenantId}/jobs/topic/tenant/{tenantId}/summary
- UI updates live without polling
job-queue-system/
├── backend/ # Spring Boot
├── frontend/ # React Vite
├── docker-compose.yml
└── .env
git clone <YOUR_REPO_URL>
cd job-queue-systemdocker compose up --build✅ Services:
- Backend: http://localhost:8080
- Frontend: http://localhost:5173
- Postgres: localhost:5432
- Redis: localhost:6379
curl http://localhost:8080/actuator/health
curl http://localhost:8080/actuator/health/readiness
curl http://localhost:8080/actuator/health/livenessPOST /api/jobs
Headers:
X-Tenant-Id: user1
Body:
{
"payload": { "type": "demo", "shouldFail": false },
"idempotencyKey": "abc-123"
}GET /api/jobs/{jobId}
GET /api/jobs?page=0&size=20
Optional filter:
GET /api/jobs?status=DONE&page=0&size=20
GET /api/jobs?status=DLQ&page=0&size=20
GET /api/jobs/summary
Copy paste these commands in order.
Open frontend dashboard first: http://localhost:5173
📌 Submit a successful job
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d '{"payload":{"type":"demo","shouldFail":false},"idempotencyKey":"success-1"}'📌 Observe dashboard live updates:
- Pending increments
- Running increments
- Done increments
📌 Confirm summary via REST
curl -H "X-Tenant-Id: user1" http://localhost:8080/api/jobs/summary📌 Submit a job that always fails
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d '{"payload":{"type":"demo","shouldFail":true},"idempotencyKey":"fail-1"}'📌 Watch retries happen and finally DLQ in dashboard
📌 List DLQ jobs from API
curl -H "X-Tenant-Id: user1" "http://localhost:8080/api/jobs?status=DLQ&page=0&size=20"📌 Submit same job twice with same idempotencyKey
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d '{"payload":{"type":"demo","shouldFail":false},"idempotencyKey":"idem-1"}'
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d '{"payload":{"type":"demo","shouldFail":false},"idempotencyKey":"idem-1"}'📌 Verify the same jobId is returned and deduplicated=true
📌 Submit 11 jobs quickly (should block at 11)
for i in {1..11}; do
curl -s -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d "{\"payload\":{\"type\":\"demo\",\"i\":$i},\"idempotencyKey\":\"rate-$i\"}" \
&& echo ""
done📌 Expect HTTP 429 RATE_LIMIT_EXCEEDED for the last one
📌 Submit many jobs for same tenant
for i in {1..20}; do
curl -s -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user1" \
-d "{\"payload\":{\"type\":\"demo\"},\"idempotencyKey\":\"conc-$i\"}" \
&& echo ""
done📌 Observe:
- At most 5 jobs show as RUNNING at any time
- Remaining jobs stay PENDING until slots free up
📌 Submit jobs for tenant user2
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: user2" \
-d '{"payload":{"type":"demo","shouldFail":false},"idempotencyKey":"u2-1"}'📌 In frontend, change tenant input to user2
📌 Confirm user2 sees their own summary/jobs only
📌 Health endpoint
curl http://localhost:8080/actuator/health📌 Metrics list
curl http://localhost:8080/actuator/metrics📌 Check job submission count
curl http://localhost:8080/actuator/metrics/jobs_submitted_total✅ Pros:
- Simple setup (only Postgres needed)
- Durable persistence
- Easy to inspect/debug via SQL
- Works well for assignment scale
- Higher DB load for large throughput systems
- Not as scalable as dedicated queue systems
✅ Pros:
- Correct distributed processing (multiple workers safe)
- No duplicate processing
- Standard Postgres queue pattern
- Requires careful indexing and transaction handling
- DB becomes bottleneck at very high throughput
✅ Pros:
- Jobs recover if worker crashes
- Avoids permanent stuck RUNNING jobs
- Needs proper lease duration tuning
- Reclaiming might re-run a job (at-least-once semantics)
✅ Pros:
- Transient failures automatically recover
- Permanent failures don't block the queue
- DLQ enables manual inspection/debugging
- Without exponential backoff, retries can happen fast (can be added as enhancement)
✅ Pros:
- Very fast
- Works across multiple backend instances
- Simple to implement
- Fixed window boundary issue (burst at minute boundary)
✅ Pros:
- Near real-time UI updates
- Less overhead compared to polling
- More moving parts than polling
- Needs tenant-topic separation to avoid leaking data
- Exponential backoff retries
- Separate worker service container (true microservice)
- Better pagination + search on backend
- Job cancellation endpoint
- Persistent job logs table (job_events)
- Prometheus + Grafana dashboard
docker compose downReset DB:
docker compose down -v