Chapter 60: Design Distributed Task Scheduler

1. Requirements

Functional Requirements

Requirement Description Examples
One-Time Tasks Schedule task to execute once at specific time Send notification at 3 PM, process batch at midnight
Recurring Tasks Execute tasks on cron schedule Daily reports, hourly cleanup, weekly backups
Priority Support High-priority tasks execute before low-priority Critical alerts > routine maintenance
Task Cancellation Cancel scheduled or running tasks User cancels scheduled job
Task Status Query Check task execution status and history Pending, running, completed, failed

Non-Functional Requirements

Requirement Target Rationale
Throughput 100M tasks/day (1,150 tasks/sec) Support enterprise-scale workloads
Latency Execute within 1 second of scheduled time Near real-time execution for time-sensitive tasks
Reliability At-least-once execution guarantee No tasks lost due to failures
Availability 99.9% uptime Critical infrastructure component
Durability Tasks persisted to disk Survive crashes and restarts
Scalability Horizontal scaling for workers and schedulers Handle load spikes and growth
Interview Tip: Clarify execution semantics early. At-least-once is standard (duplicates tolerated), exactly-once is complex and expensive. Most systems accept at-least-once with idempotent task handlers.

2. High-Level Design

graph TB Client[Client API] API[API Gateway
REST/gRPC] Scheduler[Scheduler Service
Leader Election] TaskStore[(Task Store
PostgreSQL)] ReadyQueue[Ready Queue
Redis/Kafka] WorkerPool[Worker Pool
Auto-Scaling] DLQ[Dead Letter Queue
Failed Tasks] Monitor[Monitoring
Prometheus/Grafana] Client -->|Submit Task| API API -->|Store| TaskStore Scheduler -->|Poll Due Tasks| TaskStore Scheduler -->|Enqueue| ReadyQueue WorkerPool -->|Poll| ReadyQueue WorkerPool -->|Update Status| TaskStore WorkerPool -->|Failed After Retries| DLQ Scheduler -.->|Metrics| Monitor WorkerPool -.->|Metrics| Monitor

Component Responsibilities

Component Responsibility Technology Options
API Gateway Task submission, cancellation, status queries REST API, gRPC, GraphQL
Task Store Persist tasks, scheduling metadata, execution history PostgreSQL, MongoDB, DynamoDB
Scheduler Service Poll due tasks, enqueue to ready queue Custom service with leader election (Zookeeper, etcd)
Ready Queue Hold tasks ready for immediate execution Redis (ZSET), Kafka, RabbitMQ, SQS
Worker Pool Execute tasks, retry on failure, update status Kubernetes pods, EC2 instances, Lambda
Dead Letter Queue Store permanently failed tasks for investigation SQS DLQ, Kafka topic, database table
Interview Tip: Separate scheduling (time-based logic) from execution (worker logic). Scheduler is stateless and can be replicated with leader election. Workers are horizontally scalable.

3. Task Model

Task Schema

Field Type Description Example
task_id UUID Unique task identifier a1b2c3d4-e5f6-7890-abcd-ef1234567890
task_type String Task handler type send_email, process_batch, cleanup_logs
payload JSON Task execution parameters {"user_id": 123, "email": "user@example.com"}
schedule_type Enum one_time, recurring one_time
scheduled_at Timestamp When to execute (one-time) 2026-05-12T15:00:00Z
cron_expression String Cron schedule (recurring) 0 9 * * MON-FRI (9 AM weekdays)
priority Integer 0 (highest) to 10 (lowest) 3
timeout Integer Max execution time (seconds) 300
max_retries Integer Retry attempts before DLQ 3
retry_backoff String exponential, linear, fixed exponential
status Enum pending, running, completed, failed, cancelled pending
attempt_count Integer Current retry attempt 0
last_error Text Error message from last failure Connection timeout to database
created_at Timestamp Task creation time 2026-05-12T14:00:00Z
started_at Timestamp Execution start time 2026-05-12T15:00:01Z
completed_at Timestamp Execution completion time 2026-05-12T15:02:34Z
worker_id String Worker executing the task worker-pod-7a3c
lease_expires_at Timestamp Worker lease expiration (for recovery) 2026-05-12T15:05:00Z

Indexes for Performance

Index Columns Purpose
idx_scheduled_tasks (status, scheduled_at, priority) Scheduler polling for due tasks by priority
idx_task_type (task_type, status) Query tasks by type and status
idx_lease_expiration (lease_expires_at, status) Find expired leases for recovery
idx_created_at (created_at) Historical queries and cleanup

4. Scheduling Approaches

Approach 1: Polling Database

Aspect Details
Mechanism Scheduler polls database every N seconds for tasks where scheduled_at <= NOW()
Query SELECT * FROM tasks WHERE status='pending' AND scheduled_at <= NOW() ORDER BY priority, scheduled_at LIMIT 1000
Pros Simple, durable (DB-backed), no additional infrastructure
Cons Database load scales with task count, polling delay (1-5 sec granularity), inefficient for millions of tasks
Use Case Low-scale systems (<10K tasks/day), non-latency-sensitive workloads

Approach 2: Delay Queue (Redis ZSET)

Aspect Details
Mechanism Store tasks in Redis sorted set (ZSET) with scheduled_at as score. Poll with ZRANGEBYSCORE.
Commands ZADD delay_queue {timestamp} {task_id}
ZRANGEBYSCORE delay_queue 0 {now} LIMIT 0 1000
Pros Fast (in-memory), efficient polling, sub-second latency, handles millions of tasks
Cons Redis durability trade-off (AOF/RDB), memory constraints for large task counts
Use Case Medium-scale systems (1M-100M tasks/day), low-latency requirements

Approach 3: Time-Wheel (Kafka-Based)

Aspect Details
Mechanism Partition time into buckets (e.g., 1-minute slots). Store tasks in Kafka topic partitioned by time bucket.
Example Task scheduled for 15:37:00 goes to bucket 937 (minutes since epoch % total buckets)
Pros Extremely high throughput, distributed, durable (Kafka replication), O(1) scheduling
Cons Complex implementation, time-wheel granularity trade-off, rebalancing on partition changes
Use Case Large-scale systems (100M+ tasks/day), high-throughput batch processing

Comparison Matrix

Criteria Polling DB Redis Delay Queue Kafka Time-Wheel
Throughput Low (1K tasks/sec) Medium (10K tasks/sec) High (100K+ tasks/sec)
Latency 1-5 seconds <1 second <1 second
Complexity Low Medium High
Durability High (DB WAL) Medium (Redis AOF) High (Kafka replication)
Scalability Vertical (DB) Horizontal (Redis Cluster) Horizontal (Kafka partitions)
Operational Overhead Low Medium High
Interview Tip: For 100M tasks/day requirement, recommend Redis delay queue as sweet spot (proven, fast, moderate complexity). Mention Kafka time-wheel as future optimization if scaling beyond 1B tasks/day.

5. Cron Scheduling

Cron Expression Format

Field Values Special Characters Example
Minute 0-59 * , - / */15 (every 15 min)
Hour 0-23 * , - / 9-17 (9 AM to 5 PM)
Day of Month 1-31 * , - / L W L (last day of month)
Month 1-12 or JAN-DEC * , - / JAN,APR,JUL,OCT (quarterly)
Day of Week 0-6 or SUN-SAT * , - / L # MON-FRI (weekdays)

Cron Expression Examples

Expression Description Use Case
0 0 * * * Daily at midnight Daily reports, log rotation
0 9 * * MON-FRI 9 AM on weekdays Business day notifications
*/5 * * * * Every 5 minutes Health checks, monitoring
0 0 1 * * First day of month at midnight Monthly billing, reports
0 0 * * 0 Sundays at midnight Weekly backups
0 2 * * SUN Sundays at 2 AM Maintenance windows

Cron Task Lifecycle

Step Action Details
1. Registration Store cron task Persist cron_expression, task_type, payload. Store next_run_at timestamp.
2. Calculation Compute next run time Parse cron expression. Calculate next occurrence from current time. Account for timezone.
3. Scheduling Create one-time task instance Generate task with scheduled_at = next_run_at. Link to parent cron task.
4. Execution Worker executes task Standard task execution flow.
5. Regeneration Schedule next occurrence After completion, calculate next_run_at. Create new task instance. Repeat cycle.

Timezone Handling

Challenge Solution
DST Transitions Use timezone-aware libraries (pytz, ZonedDateTime). Recalculate next_run in target timezone.
Cross-Timezone Users Store user's timezone with cron task. Convert to UTC for scheduling. Display in user's local time.
Missing Hours (DST forward) If scheduled time doesn't exist (2 AM on spring forward), skip to next occurrence or execute immediately after transition.
Duplicate Hours (DST backward) Execute only once using UTC timestamps to avoid double execution.
Interview Tip: Cron parsing is complex. Mention using battle-tested libraries (croniter, quartz) rather than implementing from scratch. Discuss edge cases: DST, leap years, invalid dates (Feb 30).

6. Task Assignment

Pull-Based Model (Recommended)

Aspect Details
Mechanism Workers poll ready queue and pull tasks when idle
Implementation Worker: task = queue.pop(timeout=10); execute(task); ack(task)
Load Balancing Natural load balancing (fast workers get more tasks)
Failure Handling Worker crash: lease timeout returns task to queue
Pros Simple, self-balancing, no central coordinator, worker auto-scaling friendly
Cons Potential thundering herd on empty queue, need lease mechanism

Push-Based Model

Aspect Details
Mechanism Central scheduler assigns tasks to workers via RPC/gRPC
Implementation Scheduler: worker = select_least_loaded(); worker.execute(task)
Load Balancing Scheduler tracks worker load, uses round-robin or least-connections
Failure Handling Scheduler detects worker failure via heartbeat, reassigns tasks
Pros Fine-grained control, optimal placement (e.g., affinity-based routing)
Cons Scheduler is bottleneck, complex state management, harder to scale

Scheduler High Availability

Component Strategy Implementation
Leader Election Single active scheduler, hot standbys Zookeeper, etcd, Redis SETNX with TTL
Heartbeat Leader sends heartbeat every 5 sec Update TTL in coordination service
Failover Standby detects leader failure (no heartbeat for 15 sec), becomes new leader Acquire lock in coordination service
Split-Brain Prevention Use fencing tokens (monotonic counter) Queue rejects writes from old leader with lower token

Leader Election Example (etcd)

// Scheduler startup
leader_key = "/schedulers/leader"
session = etcd.create_session(ttl=10)  // 10-sec TTL

while true:
    try:
        // Try to acquire leadership
        etcd.put(leader_key, scheduler_id, lease=session)
        LOG("Became leader")

        // Run scheduling loop
        while is_leader():
            poll_and_enqueue_tasks()
            time.sleep(1)
    except LeaseExpired:
        LOG("Lost leadership, entering standby mode")
        time.sleep(5)  // Wait before retry
Interview Tip: Pull-based is industry standard (Celery, Temporal, Sidekiq) for good reason: simplicity and natural load balancing. Only choose push-based if you need advanced placement logic (GPU affinity, geographic routing).

7. Exactly-Once Execution

Challenge: Duplicate Execution Scenarios

Scenario Root Cause Example
Worker Crash Worker pulls task, crashes mid-execution, task re-queued Payment processed, worker dies before ack, re-processed
Network Partition Worker loses connection, scheduler assumes failure, reassigns task Email sent, connection drops, email sent again
Slow Worker Task exceeds lease timeout, scheduler reassigns to another worker DB update takes 5 min, lease expires at 3 min, duplicate update
Split-Brain Two schedulers both think they are leader Both schedulers enqueue same task

Solution 1: Distributed Locking (Redis)

Step Action Redis Command
1. Acquire Lock Worker attempts to acquire lock for task_id SET lock:task_id worker_id NX PX 60000
2. Execute If lock acquired, execute task Process task logic
3. Heartbeat Extend lock TTL every 10 sec if still executing PEXPIRE lock:task_id 60000 (with Lua script to verify owner)
4. Release Delete lock after completion DEL lock:task_id (with Lua script to verify owner)
Conflict If lock acquisition fails, another worker is executing Skip task or wait and retry

Correct Lock Release (Lua Script)

-- Only delete lock if owned by this worker (prevent zombie deletion)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

-- Usage: EVAL script 1 lock:task_id worker_id

Solution 2: Lease-Based Execution

Step Action Database Update
1. Claim Task Worker claims task with compare-and-swap UPDATE tasks SET status='running', worker_id='w1', lease_expires_at=NOW()+60 WHERE task_id=? AND status='pending'
2. Check Verify claim succeeded (updated row count = 1) If 0 rows updated, another worker claimed it
3. Execute Perform task work Business logic
4. Heartbeat Extend lease every 20 sec UPDATE tasks SET lease_expires_at=NOW()+60 WHERE task_id=? AND worker_id='w1'
5. Complete Mark task completed UPDATE tasks SET status='completed' WHERE task_id=? AND worker_id='w1'
Timeout Scheduler finds expired leases, resets to pending UPDATE tasks SET status='pending', worker_id=NULL WHERE lease_expires_at < NOW()

Solution 3: Fencing Tokens

Aspect Details
Concept Monotonically increasing token per task. Higher token invalidates lower token.
Implementation Task store maintains fencing_token (bigint, auto-increment). When task reassigned, increment token.
Worker Logic Worker receives task with token=42. Before writing results, verify current token still 42. If 43, abort (another worker took over).
Example Worker A gets task (token=42), hangs. Scheduler reassigns to Worker B (token=43). Worker A wakes up, tries to write results, check fails (token mismatch).
Pros Prevents zombie workers from corrupting state
Interview Tip: True exactly-once is expensive and often unnecessary. Most systems use at-least-once with idempotent handlers (e.g., INSERT with unique constraint, upsert with version). Discuss trade-offs: idempotency is simpler than distributed locking.

8. Priority Queue

Priority Levels

Priority Value Use Case SLA
Critical 0 Security alerts, system failures Execute immediately (<1 sec)
High 1-3 User-facing notifications, time-sensitive operations <5 seconds
Normal 4-6 Background jobs, report generation <1 minute
Low 7-9 Cleanup, archival, non-urgent maintenance <1 hour
Batch 10 Bulk processing, analytics Best effort

Implementation: Multiple Queues

Queue Priority Range Worker Allocation
critical_queue 0 20% of workers
high_queue 1-3 40% of workers
normal_queue 4-6 30% of workers
low_queue 7-10 10% of workers

Starvation Prevention: Priority Aging

Mechanism Formula Example
Age-Based Boost effective_priority = initial_priority - (age_in_minutes / 10) Priority 8 task waiting 50 min becomes priority 3
Time-To-Live After waiting N minutes, escalate to next priority tier Low-priority task waiting 30 min promoted to normal
Fair Share Reserve X% of worker capacity for each priority level Guarantee 10% of workers always serve low-priority queue

Redis Priority Queue Implementation

-- Enqueue with priority as score (lower = higher priority)
ZADD task_queue {priority + timestamp_nanos} {task_id}

-- Worker poll: Get highest priority (lowest score) tasks
ZPOPMIN task_queue 10

-- With aging: Recompute scores periodically
-- Scheduler background job (every 1 min)
tasks = ZRANGE task_queue 0 -1 WITHSCORES
for task, score in tasks:
    age_minutes = (now - task.created_at).minutes
    new_score = task.initial_priority - (age_minutes / 10)
    ZADD task_queue new_score task.id

Weighted Fair Queuing

Priority Weight Polling Strategy
Critical 50 Poll critical queue 50 times per cycle
High 30 Poll high queue 30 times per cycle
Normal 15 Poll normal queue 15 times per cycle
Low 5 Poll low queue 5 times per cycle
Interview Tip: Strict priority causes starvation. Low-priority tasks can wait forever if high-priority tasks keep arriving. Always implement aging or fair-share guarantees to prevent indefinite starvation.

9. Failure Handling

Retry Strategy: Exponential Backoff

Attempt Delay Formula Delay (base=2s, multiplier=2) Cumulative Time
1 base * multiplier^0 2 seconds 2s
2 base * multiplier^1 4 seconds 6s
3 base * multiplier^2 8 seconds 14s
4 base * multiplier^3 16 seconds 30s
5 base * multiplier^4 32 seconds 62s
6 min(base * multiplier^5, max_delay) 64 seconds (or max cap) 126s

Retry Configuration

Parameter Description Typical Value
max_retries Maximum retry attempts before DLQ 3-5
base_delay Initial retry delay 1-5 seconds
multiplier Backoff multiplier 2 (double each time)
max_delay Cap on retry delay 300 seconds (5 min)
jitter Random offset to prevent thundering herd 0-25% of computed delay

Retry Decision Logic

Error Type Retryable? Reason
Network Timeout Yes Transient issue, likely to succeed on retry
503 Service Unavailable Yes Downstream service temporarily down
429 Rate Limit Yes (with backoff) Retry after rate limit window expires
DB Deadlock Yes Transaction conflict, retry should succeed
400 Bad Request No Invalid input, retry won't fix it
401 Unauthorized No Invalid credentials, requires manual fix
404 Not Found No Resource doesn't exist, retry pointless
Validation Error No Task payload is malformed

Retry Implementation

def execute_task_with_retry(task):
    attempt = 0
    while attempt <= task.max_retries:
        try:
            result = execute_task(task)
            mark_completed(task, result)
            return
        except RetryableError as e:
            attempt += 1
            if attempt > task.max_retries:
                send_to_dlq(task, e)
                return

            delay = calculate_backoff(attempt, task.base_delay, task.multiplier, task.max_delay)
            jitter = random.uniform(0, delay * 0.25)

            LOG(f"Attempt {attempt} failed: {e}. Retrying in {delay + jitter}s")
            update_task_status(task.id, status='retrying', attempt=attempt, next_retry_at=now() + delay + jitter)
            reschedule_task(task, delay + jitter)
            return
        except NonRetryableError as e:
            LOG(f"Non-retryable error: {e}. Sending to DLQ")
            send_to_dlq(task, e)
            return

Dead Letter Queue Handling

Action Implementation
Store Failed Task Move task to DLQ with full context (payload, error, stack trace, attempts)
Alert Send alert to on-call engineer (PagerDuty, Slack)
Investigate Engineer reviews DLQ, identifies root cause (bug, bad data, external dependency down)
Fix Deploy fix (code patch, data correction, config change)
Replay Re-enqueue tasks from DLQ back to main queue for re-execution
Discard If task is no longer relevant (e.g., stale notification), mark as discarded
Interview Tip: Distinguish transient vs. permanent failures. Blindly retrying validation errors wastes resources. Add jitter to exponential backoff to prevent synchronized retry storms after mass failures.

10. Scalability

Partitioning Strategy

Approach Partition Key Pros Cons
Hash-Based hash(task_id) % num_partitions Uniform distribution, simple No affinity, rebalancing on partition change
Range-Based scheduled_at timestamp ranges Natural for time-based queries Hot partitions (recent tasks), requires rebalancing
Task-Type-Based hash(task_type) Affinity (same worker pool handles same types) Uneven load if some types dominate
Priority-Based priority level Dedicated resources per priority Requires careful capacity planning

Scheduler Sharding

Component Scaling Strategy
Single Scheduler 1 active scheduler with leader election. Handles up to 10K tasks/sec. Simple but bottleneck.
Multiple Schedulers (Sharded) Each scheduler owns subset of partitions. Scheduler 1: partitions 0-9, Scheduler 2: partitions 10-19. Scales to 100K+ tasks/sec.
Shard Assignment Consistent hashing or static configuration. On scheduler failure, reassign its partitions to remaining schedulers.
Coordination Use Zookeeper/etcd to track scheduler-to-partition mapping. Schedulers watch for changes and rebalance.

Worker Auto-Scaling

Metric Scale-Up Threshold Scale-Down Threshold
Queue Depth > 1000 tasks waiting < 100 tasks waiting for 5+ min
Queue Age Oldest task > 2 min old All tasks < 10 sec old
Worker Utilization > 80% of workers busy < 30% of workers busy for 10+ min
Task Throughput Incoming rate > processing rate Processing rate > 2x incoming rate

Auto-Scaling Policy

// Kubernetes HPA (Horizontal Pod Autoscaler) config
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: task-worker-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: task-worker
  minReplicas: 10
  maxReplicas: 100
  metrics:
  - type: External
    external:
      metric:
        name: redis_queue_depth
        selector:
          matchLabels:
            queue: ready_queue
      target:
        type: AverageValue
        averageValue: "50"  // Target 50 tasks per worker
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60  // Wait 1 min before scale up
      policies:
      - type: Percent
        value: 50  // Scale up by 50% at a time
        periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  // Wait 5 min before scale down
      policies:
      - type: Pods
        value: 1  // Scale down 1 pod at a time
        periodSeconds: 120

Database Optimization

Optimization Implementation Impact
Read Replicas Route status queries to read replicas, writes to primary Reduce load on primary, improve query latency
Archival Move completed tasks older than 30 days to cold storage (S3, archive DB) Keep active table small, fast queries
Partitioning Partition tasks table by created_at (monthly partitions) Faster queries, easy archival (drop old partitions)
Index Tuning Composite index on (status, scheduled_at, priority) for scheduler poll Index-only scan, avoid table lookups
Connection Pooling Use PgBouncer for PostgreSQL, limit connections per worker Prevent connection exhaustion

Scalability Numbers

Component Capacity Bottleneck Solution
PostgreSQL 10K writes/sec Disk I/O, connection limit Sharding, write-through cache, read replicas
Redis (single node) 100K ops/sec Single-threaded, memory Redis Cluster (sharding), more memory
Kafka 1M msgs/sec Partition count, broker count More partitions, more brokers
Worker (CPU-bound) 10 tasks/sec CPU per worker Horizontal scaling (more workers)
Worker (I/O-bound) 100 tasks/sec Async I/O, connection pooling Increase concurrency per worker
Interview Tip: For 100M tasks/day (1,150 tasks/sec avg, 10K tasks/sec peak), Redis delay queue + 100 workers + PostgreSQL with read replicas is sufficient. Discuss monitoring: queue depth, worker lag, error rate, P99 latency. Mention graceful degradation: shed low-priority tasks under load.

Design Summary

Chosen Architecture

Component Technology Rationale
Task Store PostgreSQL (sharded) Strong consistency, ACID, complex queries
Ready Queue Redis ZSET (delay queue) Fast, sub-second latency, proven at scale
Scheduler Multiple instances (sharded by partition) High availability, horizontal scaling
Workers Kubernetes pods (auto-scaling) Pull-based, elastic capacity
Coordination etcd Leader election, shard assignment

Key Trade-Offs

Decision Chosen Alternative Reason
Execution Semantics At-least-once + idempotency Exactly-once with locking Simpler, better performance, acceptable for most tasks
Task Assignment Pull-based (workers poll) Push-based (scheduler assigns) Natural load balancing, easier to scale workers
Scheduling Redis delay queue Kafka time-wheel Sweet spot for 100M tasks/day, less complexity
Priority Multiple queues + aging Single queue with priority score Prevents starvation, clearer SLAs

Monitoring Dashboards

Metric Alert Threshold Action
Queue Depth > 10,000 tasks Scale up workers, investigate backlog
Task Lag (age of oldest task) > 5 minutes Check worker health, scale up
Error Rate > 5% Investigate errors, check DLQ
DLQ Size > 100 tasks Review failed tasks, fix root cause
Worker CPU > 90% Scale up workers or optimize task handlers
P99 Execution Latency > 60 seconds Investigate slow tasks, optimize or increase timeout
Scheduler Downtime > 10 seconds Leader election issue, check etcd