llm-driven-system-design / job-scheduler
CLAUDE.md60 starsChanged 10 months agoMITView on GitHub
A distributed, cron-capable job scheduler with priority queueing, worker pools, and at-least-once execution. The hard problems are the classic distributed-systems ones: exactly one scheduler must decide what to run (or jobs double-fire), a crashed worker mid-job must not lose the work (or jobs silently vanish), and a poison job must not retry forever (or it starves the pool). The design answers these with Redis leader election, a visibility-timeout queue, and bounded exponential-backoff retries with a dead-letter queue. Learning goals: distributed…
Files
Job Scheduler — Development with Claude
Project Context
A distributed, cron-capable job scheduler with priority queueing, worker pools, and at-least-once execution. The hard problems are the classic distributed-systems ones: exactly one scheduler must decide what to run (or jobs double-fire), a crashed worker mid-job must not lose the work (or jobs silently vanish), and a poison job must not retry forever (or it starves the pool). The design answers these with Redis leader election, a visibility-timeout queue, and bounded exponential-backoff retries with a dead-letter queue.
Learning goals: distributed coordination (leader election, distributed locks), at-least-once delivery with recovery, priority scheduling on a sorted set, and failure isolation with circuit breakers.
Architecture at a Glance (what actually runs)
Two datastores and several independently-scaled process roles — this matches docker-compose.yml and backend/package.json:
| Component | Tech | Role |
PostgreSQL 15 (pg) | Source of truth | jobs (definitions, cron schedule, next_run_time, priority, retry policy), job_executions (per-attempt history), execution_logs, users |
Redis / Valkey 7 (ioredis) | Coordination + queue | Priority queue (job_scheduler:queue sorted set), in-flight set (:processing with timeout score), dead-letter list (:dead_letter), leader lock, per-job distributed locks |
| API service | Express (port 3001) | Stateless job/execution CRUD + metrics; serves the dashboard |
| Scheduler service | Node process | Leader-elected; runs scan / stalled-recovery / retry / metrics loops |
| Workers 1–3 | Node processes | Pull from the shared queue, MAX_CONCURRENT_JOBS=5 each, run handlers |
| Frontend | React 18 + TanStack Router + Zustand | Dashboard: jobs, executions, workers, DLQ |
Supporting libraries that matter: cron-parser (computes next_run_time for recurring jobs), opossum (per-handler circuit breakers), prom-client (17+ metrics at GET /metrics), pino (structured logs). Built-in handlers: http.webhook, shell.command, system.cleanup, and test.echo/delay/log.
Key Design Decisions
1. Single active scheduler via Redis leader election
LeaderElection uses SET key instanceId EX 30 NX to claim leadership, refreshes it on a heartbeat at TTL/3 (~10s), and releases it with a Lua compare-and-delete so it can never delete another instance's lock. Only the leader scans for due jobs. Trade-off: this makes scheduling a single-writer operation (no duplicate enqueues), but leadership failover has a gap — if the leader dies, up to ~30s (the lock TTL) can pass before a standby claims the lock and scanning resumes. That's acceptable because jobs are time-windowed, not sub-second, and a brief scheduling pause is far cheaper than the double-firing a multi-leader design would cause.
2. At-least-once via a visibility-timeout queue
enqueue does ZADD with an inverted-priority score (Date.now() - priority*1_000_000) so higher priority dequeues first via ZPOPMIN; dequeue atomically pops and records the item in the :processing set with a future timeout score. A completed job is ZREM'd; a job whose worker crashes leaves its :processing entry, and the scheduler's recovery loop (ZRANGEBYSCORE … -inf now) re-enqueues anything past its timeout. Trade-off: this is at-least-once, not exactly-once — a worker that stalls past the visibility timeout but is still alive can have its job re-run, so handlers must be idempotent. We accept possible duplicates to guarantee no lost executions, which is the right bias for a scheduler.
3. Bounded exponential backoff + dead-letter queue
Retries follow min(initial_backoff_ms * 2^attempt, max_backoff_ms) up to max_retries; the scheduler's retry loop creates a fresh job_executions row (attempt+1) rather than mutating the old one, preserving full attempt history. After the retry budget is exhausted the item is LPUSH'd to the dead-letter list. Trade-off: a genuinely broken job is parked for human inspection instead of retried forever — we give up automatic self-healing for that job in exchange for not letting one poison payload burn worker capacity.
4. Per-handler circuit breakers (Opossum)
Each handler type gets its own breaker (opens at 50% error rate over 5 requests, tests recovery after 30s). If the webhook target is down, its breaker opens and those jobs are requeued for later rather than counted as failures — isolating one bad dependency from the shell/log handlers. Trade-off: added state and tuning per handler, justified because a single failing external endpoint would otherwise drain every worker slot.
5. Separate scheduler and worker processes
Scheduling (leader-only, coordination-heavy) and execution (embarrassingly parallel, stateless) scale on different axes, so they're different entry points (scheduler/index.ts, worker/index.ts) sharing the same codebase. Trade-off: more processes to run locally, bought back by being able to add workers without touching the scheduler.
Current State
Implemented and running end to end: job CRUD + pause/resume/trigger, cron and one-shot scheduling, the leader-elected scheduler with all four loops (scan, stalled-recovery, retry, metrics), three workers with bounded concurrency, the reliable priority queue with visibility timeout and DLQ, exponential-backoff retries, per-handler Opossum circuit breakers, a full prom-client metric set at GET /metrics, structured pino logging, and the React dashboard (jobs / executions / workers / DLQ views).
Note: monitoring is implemented, not pending — the earlier checklist-style CLAUDE.md listed "Add monitoring" as "Not started," which contradicted src/shared/metrics.ts and the Opossum breakers already emitting metrics.
Auth is session-based and enforced on every /api/v1 route. Users live in Redis (job_scheduler:user:*), not in the Postgres users table — that table is seeded but nothing reads it, which is a wart worth knowing about. ensureAdminUser() creates admin / admin123 on API boot (override with ADMIN_USERNAME / ADMIN_PASSWORD); that is the login the dashboard and the screenshot harness use.
Intentionally omitted: DAG/job-dependency workflows, multi-tenancy (jobs are single-tenant), request rate limiting, and a comprehensive automated test suite.
Iteration & Repair Log
- Metrics + circuit-breaker pass.
src/shared/metrics.tsandsrc/shared/circuit-breaker.tswere added, wiringprom-clientand Opossum through the scheduler/worker/API and instrumenting the queue, scheduler leadership, and per-handler breaker state. This is what makes the old "monitoring not started" note obsolete. - DB layer split. The repository was broken into
job-repository.ts,execution-repository.ts,schedule-repository.ts, andqueries.tsundersrc/db/for focus. - Migrate-at-startup + dual scripts.
migrate()is idempotent (CREATE … IF NOT EXISTS) and is called both from the scheduler on boot and vianpm run migrate/npm run db:migrate(both aliases exist). Seeding isnpm run seed/npm run db:seed. - 2026-07-29 — the project did not actually run; six separate faults. It had zero screenshots, and each fix uncovered the next:
- Every backend service failed to boot.
tsconfigsets"module": "commonjs", but 17 files used ESM-style.jsimport suffixes (from '../db/repository.js'), which CommonJSrequirecannot resolve — so api, scheduler, and all workers died on their first import withCannot find module.migrate.tswas the one file using extensionless imports, which is why migrations appeared to work while nothing else did. 67 imports rewritten to match the module system. - A stale compiled
vite.config.jsshadowedvite.config.ts.tsconfig.node.jsonis a composite project with nooutDir, sonpm run buildemitted the compiled config next to the source, and Vite resolves.jsbefore.ts. The dev server kept starting on the old port 3000 no matter what the TypeScript said. Fixed by pointing the emit atnode_modules/.tmp. docker-compose up -dstarted the whole app, not just infrastructure, binding host 3001/3000 before the local dev servers could. The health check passed against the container, hiding it. App services now sit behindprofiles: ["app"].- The frontend had no login screen at all while every
/api/v1route requires a session, so the dashboard could never load data. Addedroutes/Login.tsx, an auth store, and a gate inLayoutkeyed onauthChecked(not!user, which flashes the login form on every reload). - The dashboard called
/api/v1/metrics, which does not exist — the route is/api/v1/metrics/system, and bare/metricsis the Prometheus scrape endpoint. The 404 became a null metrics object, so every stat card rendered0while the job table beneath it was full. db-seed/seed.sqlhad an INSERT missing a value (weekly-report-generationomittednext_run_time), which aborted the transaction and left the database empty.
- Every backend service failed to boot.
npm run devnow starts the real topology (api + scheduler + 3 workers viaconcurrently), matching what docker-compose describes. Previously it ran a single all-in-one process, so the Workers page could only ever show one worker; the seeded execution history references worker-1/2/3. The old single-process entry point is still available asdev:all-in-one.- CLAUDE.md rewrite (earlier pass). Replaced the generic Phase 1–4 checklist ("Phase 3: Not started", "will be updated throughout the development process") with the real architecture and decision rationale grounded in
scheduler/index.ts,queue/reliable-queue.ts, andqueue/leader-election.ts.
Open Questions
- Job dependencies: to support DAG workflows, add a
dependenciesfield and trigger downstream jobs on completion — but how to detect and reject cycles at definition time? - Failover gap: the ~30s leadership TTL bounds worst-case scheduling latency after a leader crash. Is a shorter TTL (faster failover, more Redis chatter) or a standby pre-warm worth it?
- Exactly-once: could a per-execution distributed lock (
DistributedLockalready exists) plus a "completed" marker in Postgres upgrade at-least-once toward effectively-once for non-idempotent handlers? - Queue durability: the queue lives in Redis; on Redis loss, in-flight queue state is gone though
job_executionsin Postgres survives. Is a rebuild-from-Postgres recovery path worth adding?
Resources
- Designing a Distributed Job Scheduler
- Redis distributed locks — the SET NX EX + Lua pattern used for leader election
- cron-parser — next-run computation
- Opossum circuit breaker — per-handler failure isolation
In this repository94 other files it gives its agents.
- AGENTS.mdAGENTS.md
- 20forms-20designs/CLAUDE.mdCLAUDE.md
- ad-click-aggregator/CLAUDE.mdCLAUDE.md
- ai-code-assistant/CLAUDE.mdCLAUDE.md
- airbnb/CLAUDE.mdCLAUDE.md
- airtag/CLAUDE.mdCLAUDE.md
- amazon/CLAUDE.mdCLAUDE.md
- apns/CLAUDE.mdCLAUDE.md
- apple-maps/CLAUDE.mdCLAUDE.md
- apple-music/CLAUDE.mdCLAUDE.md
- apple-pay/CLAUDE.mdCLAUDE.md
- apple-tv/CLAUDE.mdCLAUDE.md
- app-store/CLAUDE.mdCLAUDE.md
- bitly/CLAUDE.mdCLAUDE.md
- calendly/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- coinbase/CLAUDE.mdCLAUDE.md
- collaborative-editor/CLAUDE.mdCLAUDE.md
- confluence/CLAUDE.mdCLAUDE.md
- dashboarding/CLAUDE.mdCLAUDE.md
- discord/CLAUDE.mdCLAUDE.md
- distributed-cache/CLAUDE.mdCLAUDE.md
- docusign/CLAUDE.mdCLAUDE.md
- doordash/CLAUDE.mdCLAUDE.md
- dropbox/CLAUDE.mdCLAUDE.md
- etsy/CLAUDE.mdCLAUDE.md
- excalidraw/CLAUDE.mdCLAUDE.md
- facetime/CLAUDE.mdCLAUDE.md
- fb-live-comments/CLAUDE.mdCLAUDE.md
- fb-news-feed/CLAUDE.mdCLAUDE.md
- fb-post-search/CLAUDE.mdCLAUDE.md
- figma/CLAUDE.mdCLAUDE.md
- gallery/CLAUDE.mdCLAUDE.md
- github/CLAUDE.mdCLAUDE.md
- gmail/CLAUDE.mdCLAUDE.md
- google-calendar/CLAUDE.mdCLAUDE.md
- google-docs/CLAUDE.mdCLAUDE.md
- google-search/CLAUDE.mdCLAUDE.md
- google-sheets/CLAUDE.mdCLAUDE.md
- health-data-pipeline/CLAUDE.mdCLAUDE.md
- hotel-booking/CLAUDE.mdCLAUDE.md
- icloud/CLAUDE.mdCLAUDE.md
- imessage/CLAUDE.mdCLAUDE.md
- instagram/CLAUDE.mdCLAUDE.md
- jira/CLAUDE.mdCLAUDE.md
- kindle-highlights/CLAUDE.mdCLAUDE.md
- leetcode/CLAUDE.mdCLAUDE.md
- linkedin/CLAUDE.mdCLAUDE.md
- local-delivery/CLAUDE.mdCLAUDE.md
- loom/CLAUDE.mdCLAUDE.md
- mcplator/CLAUDE.mdCLAUDE.md
- mdreader/CLAUDE.mdCLAUDE.md
- microsoft-teams/CLAUDE.mdCLAUDE.md
- netflix/CLAUDE.mdCLAUDE.md
- news-aggregator/CLAUDE.mdCLAUDE.md
- notification-system/CLAUDE.mdCLAUDE.md
- notion/CLAUDE.mdCLAUDE.md
- online-auction/CLAUDE.mdCLAUDE.md
- payment-system/CLAUDE.mdCLAUDE.md
- paypal/CLAUDE.mdCLAUDE.md
- The first 60 shown.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

