system-design46 min read

How to Design a Distributed Task Scheduler — A Senior+ Guide | Ayodhyya

How to Design a Distributed Task Scheduler

Building a Production-Grade Cron-as-a-Service — Scheduling, Execution, Reliability

Senior+ System Design Guide 10,000+ Words 20 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Task Scheduling is Hard

Every modern software system relies on scheduled tasks: nightly data aggregations, periodic report generation, cache warming, database backups, email digest delivery, ML model retraining, log rotation, and hundreds of other recurring operations. A task scheduler is the invisible infrastructure that ensures these operations run reliably, on time, and at scale. When it works perfectly, nobody notices. When it fails — missed backups, delayed reports, or cascading pipeline failures — the entire organization feels the impact.

The fundamental challenge of distributed task scheduling is coordination. In a single-machine cron setup, the scheduler is trivial: a timer fires, the command executes, done. But in a distributed environment, the scheduler must handle machine failures (what if the server running the cron job crashes mid-execution?), duplicate execution prevention (what if two scheduler instances both decide it's time to run a job?), exactly-once semantics (what if the network partitions and the scheduler doesn't know if the task completed?), and graceful scaling (what if the task load increases 10x during month-end processing?). Each of these challenges has well-known solutions, but combining them into a coherent, production-ready system requires careful engineering.

The complexity deepens when you consider task dependencies. A data pipeline might require: Step 1 (extract data from source A), Step 2 (extract data from source B), Step 3 (merge A and B — only after both complete), Step 4 (train model on merged data — only after step 3), Step 5 (deploy model — only after step 4). This forms a Directed Acyclic Graph (DAG) of task dependencies where the scheduler must manage parallel execution, handle failures at any node, and ensure correct ordering despite retries and transient errors. The DAG abstraction is fundamental to modern workflow orchestration systems like Apache Airflow, Temporal, and Cadence.

Key Insight: A distributed task scheduler is essentially a distributed consensus problem disguised as a utility service. It must answer: "Has this task been executed?" (exactly-once), "Is it time to execute?" (consistent time), and "Who should execute?" (leader election) — all in the presence of network partitions, machine failures, and clock skew.

Real-world systems operate at staggering scale. Apache Airflow at Airbnb manages over 50,000 DAGs with 150 million task runs per month. GitHub Actions processes billions of workflow runs per year. At a large SaaS company, a single task scheduler might manage millions of jobs per day across thousands of customers, each with different schedules, priorities, and resource requirements. Building a scheduler that handles this scale while maintaining reliability, fairness, and observability is a serious engineering challenge. This guide walks through every aspect of designing such a system, from high-level architecture to production-ready implementation details.

Real-World Case Studies

Understanding how major companies solve task scheduling problems provides practical insights for building our system. Each case study highlights a different aspect of the design space:

CompanySystemScaleKey Innovation
Apache Airflow (Airbnb)DAG orchestrator150M task runs/monthPull-based worker model, dynamic DAG generation
GitHub ActionsCI/CD workflow engineBillions of runs/yearContainer-native execution, marketplace of reusable actions
TemporalDurable execution engineMillions of workflows/dayEvent sourcing for replay, code-as-workflow (not config)
Cadence (Uber)Workflow orchestrationBillions of executions/yearMutable state, side-effect capture, sticky execution
Apache DolphinSchedulerEnterprise scheduler10K+ nodesMulti-tenant, visual DAG editor, worker group isolation

Temporal's approach is particularly interesting: instead of defining workflows as YAML DAGs, you write regular code with function calls, and the framework replays the execution history to recover from failures. This eliminates the impedance mismatch between "what the developer writes" and "what the scheduler executes." The downside is the learning curve of the Temporal programming model. Our system takes a middle ground: DAG definitions are structured data (YAML/JSON) for operational clarity, but task handlers are regular C# code with full IDE support.

Uber's Cadence (which evolved into Temporal) pioneered the concept of "sticky execution" — once a worker starts processing a workflow, it preferentially receives subsequent tasks from the same workflow. This maximizes cache locality and reduces state reconstruction overhead. We adopt a similar approach: our worker pool manager tracks which worker last handled a task and preferentially assigns follow-up tasks to the same worker when possible.

2. Functional & Non-Functional Requirements

Functional Requirements

  1. Task Definition: Users can define tasks with cron expressions, interval schedules, or one-shot delayed execution. Tasks can have parameters, timeouts, and retry policies.
  2. DAG Orchestration: Users can define task dependencies forming a DAG. The scheduler must execute tasks in the correct order, parallelize independent tasks, and handle failures at any node.
  3. Task Execution: Tasks execute on worker nodes with resource isolation. Workers report status (success, failure, progress) back to the scheduler.
  4. Retry & Error Handling: Failed tasks retry with configurable backoff strategies (fixed, exponential, jitter). Tasks that exhaust retries go to a dead letter queue.
  5. Monitoring & Observability: Real-time dashboards showing task status, execution history, latency percentiles, and failure rates. Alerting on missed schedules and elevated error rates.
  6. Manual Controls: Users can trigger tasks immediately, pause/resume schedules, kill running tasks, and view detailed execution logs.

Non-Functional Requirements

RequirementTargetRationale
Schedule AccuracyWithin 1 second of scheduled timeFinancial tasks and data pipelines depend on precise timing
Availability99.99%Task scheduling is infrastructure — downtime cascades
Task Throughput100K tasks/hourEnterprise-scale scheduling load
Exactly-Once ExecutionAt-least-once with idempotent tasksPrevent duplicate charges, duplicate reports
Execution Latency< 5 seconds from scheduled timeTasks should not be significantly delayed
History Retention90 days detailed, 1 year aggregateAudit and debugging requirements
Multi-TenancyResource isolation per customerSaaS scheduling platform must isolate tenants
Failover Time< 15 seconds (leader), < 2 minutes (worker)Minimize disruption during failures
Recovery Point ObjectiveZero missed tasksEvery scheduled task must eventually execute
API Rate Limit1000 req/min per tenantProtect scheduler from API abuse

Key Design Tradeoffs

TradeoffOption AOption BOur Choice
Push vs Pull dispatchScheduler pushes tasks to workers (lower latency)Workers pull tasks (better backpressure)Pull via Kafka (backpressure + decoupling)
Centralized vs Distributed stateSingle source of truth in DB (simpler)Replicated state across nodes (faster)Centralized DB + Redis cache (simplicity wins)
Event-driven vs Polling evaluationEvent-driven (lower latency, complex)Time-based polling (simpler, predictable)Polling with 1s interval (simplicity, predictable)
Exactly-once vs At-least-onceExactly-once (complex distributed transactions)At-least-once + idempotency (simpler)At-least-once with idempotent handlers

3. Capacity Estimation

Task Volume

  • Total tasks: 1 million active schedules
  • Average executions per task per day: 24 (hourly tasks)
  • Daily task runs: 24 million
  • Peak QPS (top of the hour): 50K tasks triggered per minute = ~833 QPS
  • Concurrent running tasks (peak): ~50,000

Storage

  • Task metadata: 1M tasks × 2 KB = 2 GB
  • Execution logs (90 days): 24M runs/day × 90 × 5 KB = 10.8 TB
  • Execution history aggregate: ~500 GB/year

Compute

  • Scheduler service: 3-node cluster (leader + 2 standbys)
  • Worker nodes: 200-500 nodes (auto-scaled based on queue depth)
  • Metadata database: PostgreSQL cluster (3 nodes + read replicas)

Network & Throughput

  • Task dispatch messages: 833 QPS × 1 KB = ~833 KB/s inbound to Kafka
  • Worker heartbeat reports: 500 workers × 1 heartbeat/30s = ~17 heartbeats/s
  • Execution status updates: 833 QPS × 0.5 KB = ~417 KB/s to PostgreSQL
  • Redis lock operations: ~833 SET/GET per second (1 per task trigger)
  • Total database write QPS (peak): ~2,500 (includes status updates, lock acquisitions, heartbeat renewals)
  • Kafka topic partitions: 64 partitions per topic (supports up to 64 concurrent consumers)
  • P99 end-to-end latency (schedule → start execution): < 2 seconds at 833 QPS
Critical Insight: The scheduler's bottleneck is not the trigger QPS — it is the consistent check that prevents duplicate execution. At 833 QPS, the scheduler must perform 833 "should I run this task?" checks per second, each requiring a read-modify-write to the task state. This is a write-heavy workload that must be carefully partitioned.

4. Data Model & Storage Schema

SQL
CREATE TABLE task_definitions (
    task_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    name            VARCHAR(255) NOT NULL,
    description     TEXT,
    schedule_type   VARCHAR(20) NOT NULL,  -- cron, interval, one-shot
    schedule_expr   VARCHAR(100),          -- cron expression or interval
    command         TEXT NOT NULL,          -- executable or webhook URL
    parameters      JSONB,
    timeout_seconds INTEGER DEFAULT 300,
    max_retries     INTEGER DEFAULT 3,
    retry_policy    VARCHAR(20) DEFAULT 'exponential',
    priority        INTEGER DEFAULT 5,     -- 1 (highest) to 10 (lowest)
    enabled         BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE task_executions (
    execution_id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id         UUID NOT NULL REFERENCES task_definitions(task_id),
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
        -- pending, running, success, failed, cancelled, timed_out
    scheduled_at    TIMESTAMPTZ NOT NULL,
    started_at      TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    worker_id       VARCHAR(100),
    attempt         INTEGER DEFAULT 1,
    max_attempts    INTEGER DEFAULT 3,
    result          JSONB,
    error_message   TEXT,
    logs_url        VARCHAR(1000),
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_exec_task_scheduled
    ON task_executions(task_id, scheduled_at DESC);
CREATE INDEX idx_exec_status_scheduled
    ON task_executions(status, scheduled_at)
    WHERE status IN ('pending', 'running');

CREATE TABLE dag_definitions (
    dag_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    name            VARCHAR(255) NOT NULL,
    schedule_type   VARCHAR(20) NOT NULL,
    schedule_expr   VARCHAR(100),
    enabled         BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE dag_edges (
    edge_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    dag_id          UUID NOT NULL REFERENCES dag_definitions(dag_id),
    upstream_task   UUID NOT NULL REFERENCES task_definitions(task_id),
    downstream_task UUID NOT NULL REFERENCES task_definitions(task_id)
);

CREATE TABLE dag_executions (
    dag_execution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    dag_id          UUID NOT NULL REFERENCES dag_definitions(dag_id),
    status          VARCHAR(20) NOT NULL DEFAULT 'running',
    triggered_at    TIMESTAMPTZ NOT NULL,
    completed_at    TIMESTAMPTZ
);

Storage Strategy

DataStorageRationale
Task definitionsPostgreSQL (primary) + Redis (cache)ACID for metadata changes, cache for hot reads
Execution state (pending/running)PostgreSQL + Redis (real-time status)Need atomic updates for status transitions
Execution historyPostgreSQL (90 days) → S3 (archive)Hot history for debugging, cold for compliance
Execution logsS3 + CloudWatch/ELKLarge blobs, not suitable for database
DAG statePostgreSQL (relational)Graph queries for dependency traversal
Real-time queueKafka / Redis StreamsHigh-throughput task dispatch

Partitioning Strategy

The task_executions table grows quickly — at 24M rows per day, it reaches 2.16 billion rows after 90 days. To maintain query performance, the table is partitioned by scheduled_at using PostgreSQL native range partitioning. Partitions are created monthly (each partition holds ~720M rows). Old partitions beyond 90 days are detached and archived to S3 as Parquet files, then dropped. This keeps the active dataset at ~2.16 billion rows, which PostgreSQL handles efficiently with proper indexing.

SQL
-- Partition management (run monthly via task scheduler itself!)
CREATE TABLE task_executions_2025_08
    PARTITION OF task_executions
    FOR VALUES FROM ('2025-08-01') TO ('2025-09-01');

-- Archive old partition to S3 then detach
ALTER TABLE task_executions DETACH PARTITION task_executions_2025_05;
-- (COPY to S3 via pg_dump, then DROP TABLE)

Index Design

The index strategy optimizes for three primary query patterns: (1) "Which tasks should fire next?" — scans task_definitions WHERE enabled=true, ordered by next_fire_time. This is a small dataset (1M rows) that fits entirely in the PostgreSQL shared buffers. (2) "Claim a pending task" — atomic UPDATE on task_executions WHERE status='pending' AND task_id=X. The partial index on status='pending' keeps this fast even with billions of historical rows. (3) "Show execution history for task X" — indexed on (task_id, scheduled_at DESC) for efficient range scans. (4) "DLQ investigation" — indexed on (status, created_at) WHERE status='failed' for operators browsing dead letter queue contents.

5. High-Level Architecture Overview

The distributed task scheduler consists of four main components: the Scheduler Service (determines which tasks should run and when), the Task Queue (buffers pending tasks for execution), the Worker Pool (executes tasks and reports results), and the Metadata Store (persists task definitions, execution history, and state). These components communicate through Kafka for asynchronous task dispatch and gRPC for synchronous status updates.

graph TB subgraph Clients["Client Layer"] API["REST API"] CLI["CLI Tool"] Webhook["Webhook Trigger"] end subgraph Scheduler["Scheduler Service (3-node HA)"] Leader["Leader Node"] Cron["Cron Evaluator"] DAG["DAG Resolver"] end subgraph Queue["Task Queue"] Kafka["Kafka Topics"] end subgraph Workers["Worker Pool"] W1["Worker 1"] W2["Worker 2"] W3["Worker N"] end subgraph Storage["Storage Layer"] PG["PostgreSQL"] Redis["Redis"] S3["S3 (Logs)"] end API --> Leader CLI --> Leader Webhook --> Leader Leader --> Kafka Kafka --> W1 Kafka --> W2 Kafka --> W3 W1 --> PG W1 --> S3 Leader --> PG Leader --> Redis

Request Flow: Scheduled Task Execution

  1. Evaluation: The Scheduler Leader evaluates all cron expressions every second. Tasks whose next execution time has passed are moved to the "pending" state and published to a Kafka topic partitioned by task_id. The evaluation uses an in-memory priority queue to avoid scanning all tasks every second.
  2. Dispatch: Worker nodes consume from Kafka. Each worker claims a task by performing an atomic compare-and-swap on the task's execution status (pending → running) in PostgreSQL. Only one worker succeeds, preventing duplicate execution even with Kafka's at-least-once delivery semantics.
  3. Execution: The worker executes the task command, streaming logs to S3 and updating progress in Redis. If the task exceeds its timeout, the worker kills the process and marks it as timed_out. The worker also sends periodic heartbeats to indicate it is still alive and processing.
  4. Completion: On success, the worker updates the status to "completed" and triggers any downstream DAG tasks. On failure, the scheduler increments the attempt counter and re-enqueues with a delay based on the retry policy. If max retries are exhausted, the task moves to the Dead Letter Queue.

Data Flow Patterns

The scheduler uses three distinct data flow patterns depending on the operation type:

  • Hot path (task evaluation → dispatch): The scheduler reads task definitions from PostgreSQL (cached in Redis for hot tasks), evaluates cron expressions in-memory, and publishes to Kafka. This path must complete in under 100ms to maintain schedule accuracy. The scheduler maintains an in-memory index of tasks sorted by next execution time to avoid scanning all 1M tasks every second.
  • Warm path (task execution → completion): Workers consume from Kafka, claim tasks via PostgreSQL CAS, execute, and report results. This path has a typical latency of 1-5 seconds depending on task complexity. Workers batch status updates to PostgreSQL every 100ms to reduce write amplification.
  • Cold path (monitoring → alerting): Prometheus scrapes metrics from all components every 15 seconds. Grafana dashboards aggregate these into real-time views. AlertManager evaluates rules and sends notifications to PagerDuty. This path tolerates 30-60 second latency.

6. API Design

The scheduler exposes a RESTful API for task management, execution monitoring, and administrative operations. All API calls are authenticated via JWT tokens and scoped to a tenant. The API layer enforces rate limits per tenant (configurable, default 1000 requests/minute) and validates all inputs before passing to the scheduler service.

Task Management API

HTTP
POST   /api/v1/tasks                  # Create a new task
GET    /api/v1/tasks                   # List tasks (paginated, filtered)
GET    /api/v1/tasks/{id}              # Get task details
PUT    /api/v1/tasks/{id}              # Update task definition
DELETE /api/v1/tasks/{id}              # Soft-delete a task
POST   /api/v1/tasks/{id}/trigger      # Trigger immediate execution
POST   /api/v1/tasks/{id}/pause        # Pause scheduled execution
POST   /api/v1/tasks/{id}/resume       # Resume scheduled execution
GET    /api/v1/tasks/{id}/executions   # List execution history

Example: Create Task Request

JSON
{
    "name": "daily-etl-pipeline",
    "description": "Extract, transform, and load daily sales data",
    "schedule": {
        "type": "cron",
        "expression": "0 2 * * *",
        "timezone": "America/New_York"
    },
    "handler": {
        "type": "container",
        "image": "registry.internal/etl-pipeline:v2.3",
        "command": ["dotnet", "EtlPipeline.dll", "--source=sales-db"],
        "environment": {
            "DATABASE_URL": "{{secret:sales-db-url}}",
            "LOG_LEVEL": "Information"
        },
        "resource_requirements": {
            "cpu": "2.0",
            "memory": "4Gi",
            "timeout_seconds": 3600
        }
    },
    "retry_policy": {
        "max_attempts": 3,
        "backoff": "exponential_jitter",
        "base_delay_seconds": 60,
        "max_delay_seconds": 3600
    },
    "notifications": {
        "on_failure": ["slack://data-engineering", "email://oncall@company.com"],
        "on_success": [],
        "on_dlq": ["pagerduty://critical"]
    },
    "tags": ["etl", "sales", "daily"]
}

Execution Monitoring API

HTTP
GET    /api/v1/executions                    # List all executions (global view)
GET    /api/v1/executions/{id}               # Get execution details
GET    /api/v1/executions/{id}/logs          # Stream execution logs (SSE)
POST   /api/v1/executions/{id}/cancel        # Cancel a running execution
POST   /api/v1/executions/{id}/retry         # Manually retry a failed execution
GET    /api/v1/dags/{id}/executions          # List DAG execution history
GET    /api/v1/dags/{id}/executions/{runId}  # Get DAG execution state

Admin API

HTTP
GET    /api/v1/admin/workers                  # List worker nodes and status
GET    /api/v1/admin/metrics                  # System-wide metrics summary
POST   /api/v1/admin/dlq/retry-all            # Bulk retry all DLQ tasks
GET    /api/v1/admin/dlq                      # List DLQ tasks
DELETE /api/v1/admin/dlq/{id}                 # Remove task from DLQ
GET    /api/v1/admin/scheduler/status         # Scheduler leader status
POST   /api/v1/admin/scheduler/failover       # Force leader failover (debug)
API Versioning: The API uses URL-based versioning (/api/v1/, /api/v2/). Breaking changes introduce a new version. Non-breaking changes (adding fields, new optional parameters) are added to the current version. The versioning policy is documented in an API changelog that consumers can subscribe to for notifications.

7. Task Definition & Scheduling

Task definitions encode the what, when, and how of each scheduled operation. The system supports three modes: cron expressions (flexible time-based schedules), interval schedules (fixed intervals between executions), and one-shot delayed tasks. The scheduler must parse these definitions and efficiently determine which tasks are due at any given second.

Cron Expression Parser

C#
public class CronExpression
{
    private readonly CronField _minute, _hour, _dayOfMonth, _month, _dayOfWeek;

    public static CronExpression Parse(string expression)
    {
        var parts = expression.Trim().Split(' ');
        if (parts.Length < 5 || parts.Length > 6)
            throw new InvalidCronException(expression);
        return new CronExpression
        {
            _minute = CronField.ParseMinute(parts[0]),
            _hour = CronField.ParseHour(parts[1]),
            _dayOfMonth = CronField.ParseDayOfMonth(parts[2]),
            _month = CronField.ParseMonth(parts[3]),
            _dayOfWeek = CronField.ParseDayOfWeek(parts[4])
        };
    }

    public DateTime GetNextFireTime(DateTime after)
    {
        var candidate = after.AddMinutes(1);
        candidate = new DateTime(candidate.Year, candidate.Month,
            candidate.Day, candidate.Hour, candidate.Minute, 0);
        for (int i = 0; i < 525960; i++) // max 4 years
        {
            if (_minute.Matches(candidate.Minute) &&
                _hour.Matches(candidate.Hour) &&
                _dayOfMonth.Matches(candidate.Day) &&
                _month.Matches(candidate.Month) &&
                _dayOfWeek.Matches((int)candidate.DayOfWeek))
                return candidate;
            candidate = candidate.AddMinutes(1);
        }
        throw new NoNextFireTimeException();
    }
}

Schedule Evaluation Strategies

StrategyHow It WorksProsCons
Scan AllCheck all task definitions every secondSimpleO(n) per second, doesn't scale
Priority QueueHeap ordered by next execution timeO(log n) to find due tasksMust re-insert after each execution
Time-BucketedHash tasks into 1-minute bucketsO(1) to find due tasksBoundary tasks may fire early/late

8. Scheduler Service & Leader Election

Only one scheduler instance should actively evaluate to prevent duplicate triggers. This is achieved through leader election using a distributed lock. The leader periodically renews its lock (every 5 seconds with a 15-second TTL). If the leader crashes, the lock expires and a standby acquires leadership within 15 seconds.

C#
public class SchedulerLeader
{
    private readonly IDistributedLock _leaderLock;
    private readonly ICronEvaluator _cronEvaluator;
    private readonly ITaskDispatcher _dispatcher;

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (await _leaderLock.AcquireAsync(
                "scheduler:leader", ttl: TimeSpan.FromSeconds(15)))
            {
                await RunSchedulerLoopAsync(ct);
            }
            else
            {
                await Task.Delay(TimeSpan.FromSeconds(5), ct);
            }
        }
    }

    private async Task RunSchedulerLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested &&
               await _leaderLock.IsHeldAsync())
        {
            var dueTasks = await _cronEvaluator
                .GetDueTasksAsync(windowSeconds: 2);
            foreach (var task in dueTasks)
            {
                var execution = new TaskExecution
                {
                    ExecutionId = Guid.NewGuid(),
                    TaskId = task.TaskId,
                    Status = ExecutionStatus.Pending,
                    ScheduledAt = DateTime.UtcNow
                };
                await _dispatcher.DispatchAsync(execution,
                    task.Priority);
            }
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
        }
    }
}
Clock Skew: In a distributed scheduler, clock skew can cause duplicate triggers. Use NTP-synchronized clocks with ≤500ms tolerance, or hybrid logical clocks (HLC) that combine physical time with a logical counter for monotonic ordering.

Evaluation Algorithm

The cron evaluator does not scan all 1M tasks every second — that would be wasteful. Instead, it maintains an in-memory priority queue sorted by next_fire_time. Every second, it pops tasks from the queue whose next_fire_time is within the 2-second lookahead window. After evaluating a task, it computes the next fire time and re-inserts it into the queue. This gives O(log N) per task evaluation instead of O(N) full scan.

C#
public class EfficientCronEvaluator
{
    private readonly SortedSet<ScheduledTask> _taskQueue = new();
    private readonly TimeSpan _lookahead = TimeSpan.FromSeconds(2);

    public async Task<IReadOnlyList<TaskDefinition>>
        GetDueTasksAsync()
    {
        var now = DateTime.UtcNow;
        var cutoff = now.Add(_lookahead);
        var dueTasks = new List<TaskDefinition>();

        while (_taskQueue.Count > 0)
        {
            var earliest = _taskQueue.Min;
            if (earliest.NextFireTime > cutoff)
                break; // No more due tasks

            _taskQueue.Remove(earliest);
            dueTasks.Add(earliest.Definition);

            // Recompute and re-insert
            var nextFire = earliest.Definition.Schedule
                .GetNextOccurrence(now);
            _taskQueue.Add(new ScheduledTask(
                earliest.Definition, nextFire));
        }

        return dueTasks;
    }
}

Warm Standby

To reduce failover time from 15 seconds to under 1 second, standby scheduler nodes maintain a warm cache of the task schedule. They subscribe to a read-only Kafka topic that streams task definition changes. When a standby detects leadership loss (via lock expiry), it immediately starts evaluating tasks from its cached schedule. The first evaluation may have slightly stale data (at most one task definition change behind), but this is acceptable for most use cases. Within one evaluation cycle (1 second), the standby's cache is fully synchronized with PostgreSQL.

Leader Election Best Practice: Use PostgreSQL advisory locks rather than Redis for leader election. PostgreSQL advisory locks are durable ( survive restarts via WAL) and have strong consistency guarantees. Redis SET NX EX can lose the lock entry during a Redis failover, causing split-brain where two nodes believe they are leader. PostgreSQL advisory locks survive primary failover because the lock state is replicated to standby nodes.

9. Task Queue & Execution

The task queue is the critical path between the scheduler determining a task should run and a worker actually executing it. Kafka provides the ideal backing store: it handles millions of messages per second, supports consumer groups for worker load balancing, and provides at-least-once delivery semantics. Tasks are dispatched to Kafka topics partitioned by task_id, ensuring that retries for the same task always go to the same partition (and potentially the same worker), improving cache locality.

The execution pipeline has three phases: claim, execute, and report. In the claim phase, the worker atomically updates the task status from "pending" to "running" using a compare-and-swap operation. This ensures that even if multiple workers receive the same task message (Kafka at-least-once delivery), only one worker actually executes it. The claim operation includes a heartbeat mechanism — the worker must periodically renew its claim (every 30 seconds). If the worker crashes, the heartbeat stops and the scheduler reclaims the task after a 2-minute timeout, re-dispatching it to another worker.

Worker Execution Model

C#
public class TaskWorker : BackgroundService
{
    private readonly IKafkaConsumer _consumer;
    private readonly IExecutionRepo _executionRepo;
    private readonly ILogArchiver _logArchiver;

    protected override async Task ExecuteAsync(
        CancellationToken ct)
    {
        await foreach (var message in _consumer
            .ConsumeAsync("task-execution", ct))
        {
            var execution = message.Value;

            // Atomic claim: pending → running
            var claimed = await _executionRepo
                .TryClaimAsync(execution.ExecutionId, WorkerId);
            if (!claimed) continue; // Another worker claimed it

            using var cts = new CancellationTokenSource(
                TimeSpan.FromSeconds(execution.TimeoutSeconds));
            try
            {
                var result = await ExecuteTaskAsync(
                    execution, cts.Token);
                await _executionRepo.CompleteAsync(
                    execution.ExecutionId, result);
            }
            catch (OperationCanceledException)
            {
                await _executionRepo.TimeoutAsync(
                    execution.ExecutionId);
            }
            catch (Exception ex)
            {
                await _executionRepo.FailAsync(
                    execution.ExecutionId, ex.Message);
            }
        }
    }
}

Log Streaming Pipeline

Task execution logs are streamed in real-time to S3 for storage and to Elasticsearch for searchable indexing. The worker starts a log collection process alongside the task process. The log collector reads stdout/stderr, timestamps each line, and publishes to a Kafka topic partitioned by execution_id. A log consumer service reads from this topic, batches lines into 1MB chunks, and uploads to S3 as compressed JSON files. Simultaneously, it indexes each log entry in Elasticsearch with the execution_id as the document ID. This dual-write approach ensures that logs are both durably stored (S3) and immediately searchable (Elasticsearch).

Log Retention Policy: Execution logs are retained in Elasticsearch for 30 days (hot storage on SSD), then moved to Elasticsearch warm storage (HDD) for 60 more days, then archived to S3 Glacier Deep Archive for 1 year. This tiered approach keeps hot search fast while minimizing storage costs. A typical execution generates 50KB of logs — at 24M executions/day, that's 1.2TB/day of raw logs, compressed to ~300GB/day in S3.

10. Worker Pool Management

The worker pool must dynamically scale based on queue depth and task priority. Workers are organized into priority tiers: critical workers handle high-priority tasks (SLA-sensitive), normal workers handle standard tasks, and bulk workers handle low-priority batch jobs. Each tier auto-scales independently based on its queue depth.

TierConcurrencyAuto-Scale TriggerMax Nodes
Critical100 concurrent tasks/nodeQueue depth > 50050
Normal50 concurrent tasks/nodeQueue depth > 1000200
Bulk20 concurrent tasks/nodeQueue depth > 5000100
Resource Isolation: Each worker runs tasks in isolated containers or processes. A slow or resource-intensive task in one container cannot starve other tasks on the same worker. This is achieved using cgroups (Linux), Docker containers, or Kubernetes pods with resource limits.

Worker Health Monitoring

Each worker publishes a heartbeat every 30 seconds containing: current CPU/memory utilization, number of active tasks, number of tasks completed in the last minute, and any error counts. The scheduler uses heartbeats to detect dead workers (missed 2 heartbeats = 60 seconds = worker assumed dead). When a worker is detected as dead, all its in-progress tasks are re-queued for execution by other workers. The re-queue operation is idempotent — only tasks in "running" state on the dead worker are re-queued.

C#
public class WorkerHealthMonitor
{
    private readonly ConcurrentDictionary<string, WorkerHeartbeat>
        _heartbeats = new();
    private readonly TimeSpan _deadThreshold = TimeSpan.FromSeconds(60);

    public void RecordHeartbeat(string workerId, WorkerHeartbeat hb)
    {
        _heartbeats[workerId] = hb with
        {
            ReceivedAt = DateTime.UtcNow
        };
    }

    public IReadOnlyList<string> GetDeadWorkers()
    {
        var now = DateTime.UtcNow;
        return _heartbeats
            .Where(hb => now - hb.Value.ReceivedAt > _deadThreshold)
            .Select(hb => hb.Key)
            .ToList();
    }
}

Graceful Shutdown

When a worker receives a SIGTERM signal (during deployment or scaling down), it enters a graceful shutdown mode: it stops accepting new tasks, waits for currently executing tasks to complete (up to a configurable deadline, typically 5 minutes), then publishes its final heartbeat with a "shutting down" status. The scheduler sees this status and immediately reassigns any remaining in-progress tasks to other workers. This ensures zero task loss during deployments and rolling updates.

Worker Auto-Scaling

The auto-scaler monitors Kafka consumer lag for each priority tier. If consumer lag exceeds the tier's threshold (Critical: 500, Normal: 1000, Bulk: 5000), it provisions additional workers. Scale-down happens when consumer lag drops below 10% of the threshold for 10 consecutive minutes. The scaling algorithm uses a target-tracking approach: maintain consumer lag at a target level by adjusting the number of workers proportionally. This provides smooth scaling without oscillation.

11. DAG Execution Engine

The DAG (Directed Acyclic Graph) execution engine orchestrates complex workflows where tasks have dependencies. The engine must detect cycles (invalid DAGs), determine execution order (topological sort), parallelize independent tasks, handle failures at any node, and support conditional branching. The DAG state machine tracks the status of each task in the graph and triggers downstream tasks when their dependencies complete.

DAG execution is fundamentally a graph traversal problem combined with event-driven programming. When a task in the DAG completes, the engine must check all downstream tasks to see if their dependencies are now satisfied. This check is efficient because we pre-compute an adjacency list and an in-degree count for each node. When a task completes, we decrement the in-degree of all downstream nodes. Any node whose in-degree reaches zero is ready to execute and is dispatched to the worker pool.

graph LR A["Extract Data A"] --> C["Merge Data"] B["Extract Data B"] --> C C --> D["Clean Data"] D --> E["Train Model"] D --> F["Generate Report"] E --> G["Deploy Model"] F --> H["Email Report"]

The DAG engine must handle partial failures gracefully. If task E fails but tasks F and H succeed, the engine should not fail the entire DAG — instead, it should mark E as failed, allow its downstream tasks (G) to be skipped, and complete the DAG with a "partially completed" status. This allows data pipelines to produce partial results rather than nothing at all. The DAG definition includes a "failure policy" that determines whether downstream tasks should be skipped, retried, or allowed to run despite upstream failures.

Failure Policies

PolicyBehaviorUse Case
FailAllCancel all remaining tasks in the DAGFinancial pipelines where partial results are dangerous
SkipDownstreamSkip tasks that depend on the failed task, continue othersData pipelines that can produce partial results
ContinueAllRun all tasks regardless of upstream failuresIndependent notification tasks that should always run

Conditional Branching

Beyond simple dependencies, the DAG engine supports conditional branching where downstream tasks execute only if an upstream task produces a specific output. For example: Task A classifies a document → if "invoice", execute Task B (process invoice) → if "receipt", execute Task C (process receipt). Conditional branches are evaluated by inspecting the upstream task's result JSON using a configurable expression language (JSONPath or simple predicates). The branch conditions are evaluated at DAG runtime, not at definition time, enabling dynamic workflows that adapt to their inputs.

JSON
{
    "dag": "document-processing",
    "tasks": [
        { "id": "classify", "handler": "DocumentClassifier" },
        { "id": "process-invoice", "handler": "InvoiceProcessor",
          "depends_on": ["classify"],
          "condition": { "field": "classify.result.type", "equals": "invoice" } },
        { "id": "process-receipt", "handler": "ReceiptProcessor",
          "depends_on": ["classify"],
          "condition": { "field": "classify.result.type", "equals": "receipt" } }
    ]
}
DAG Size Limits: The engine supports DAGs with up to 1,000 tasks and 10,000 edges. Larger DAGs are split into sub-DAGs with explicit handoff points. The topological sort runs in O(V+E) time — for a 1,000-task DAG with 10,000 edges, this completes in under 100ms. At runtime, each task completion triggers an O(out-degree) check, which is typically O(1-5) for most DAG structures.

DAG State Tracking

C#
public class DagStateTracker
{
    private readonly IDagRepository _dagRepo;
    private readonly ITaskDispatcher _dispatcher;

    public async Task OnTaskCompletedAsync(
        Guid dagExecutionId, Guid taskId, TaskResult result)
    {
        if (result.Status == ExecutionStatus.Failed)
        {
            var dag = await _dagRepo.GetDagAsync(dagExecutionId);
            if (dag.FailurePolicy == FailurePolicy.SkipDownstream)
            {
                await SkipDownstreamTasksAsync(
                    dagExecutionId, taskId);
            }
            else if (dag.FailurePolicy == FailurePolicy.FailAll)
            {
                await FailDagAsync(dagExecutionId, taskId);
                return;
            }
        }

        // Get downstream tasks and their dependency counts
        var downstream = await _dagRepo
            .GetDownstreamTasksAsync(dagExecutionId, taskId);

        foreach (var downstreamTask in downstream)
        {
            var ready = await _dagRepo.DecrementAndCheckReadyAsync(
                dagExecutionId, downstreamTask.TaskId);

            if (ready)
            {
                await _dispatcher.DispatchAsync(
                    downstreamTask.ExecutionId,
                    downstreamTask.Priority);
            }
        }

        // Check if entire DAG is complete
        var dagComplete = await _dagRepo
            .IsDagCompleteAsync(dagExecutionId);
        if (dagComplete)
        {
            await CompleteDagAsync(dagExecutionId);
        }
    }
}

DAG Execution Policies

PolicyBehaviorUse Case
All-or-NothingAny task failure fails the entire DAGCritical financial workflows
Skip DownstreamFailed task skips its downstream tasks, rest continuesData pipelines with partial results
Best EffortAll tasks run regardless of upstream failuresIndependent reporting tasks
Conditional BranchTask output determines which downstream branch runsML pipelines (deploy if accuracy > threshold)
Scaling DAGs: A single DAG might contain 1,000+ tasks (common in ML training pipelines). The engine must handle this efficiently by pre-computing the topological order and in-degree counts at DAG definition time, not at execution time. The runtime overhead per task completion should be O(1) — just decrement in-degree and check if zero — not O(n) where n is the total number of tasks.

12. Retry, Error Handling & Dead Letter Queue

Retry policies are critical for handling transient failures (network timeouts, temporary resource exhaustion) without manual intervention. The scheduler supports three backoff strategies: fixed (retry every 30 seconds), exponential (30s, 60s, 120s, ...), and jittered exponential (add random jitter to prevent thundering herd). After exhausting all retries, the task is moved to a Dead Letter Queue (DLQ) for manual investigation.

The retry logic must be aware of the task's idempotency. Some tasks are naturally idempotent (sending a daily report — sending it twice is harmless). Others are not (charging a credit card — charging twice is a disaster). The task definition includes an idempotency flag and an idempotency key (typically a hash of the task parameters and scheduled time). When a task is retried, the worker checks if a previous execution with the same idempotency key already succeeded. If so, the retry is skipped.

C#
public class RetryPolicy
{
    public static TimeSpan CalculateDelay(
        int attempt, string policy, TimeSpan baseDelay)
    {
        return policy switch
        {
            "fixed" => baseDelay,
            "exponential" => TimeSpan.FromSeconds(
                baseDelay.TotalSeconds * Math.Pow(2, attempt - 1)),
            "exponential_jitter" => TimeSpan.FromSeconds(
                baseDelay.TotalSeconds *
                Math.Pow(2, attempt - 1) *
                (0.5 + Random.Shared.NextDouble())),
            "linear" => TimeSpan.FromSeconds(
                baseDelay.TotalSeconds * attempt),
            _ => baseDelay
        };
    }
}
PolicyPatternBest ForRisk
FixedRetry every N secondsConsistent transient failuresCan overwhelm recovering service
Exponential BackoffN, 2N, 4N, 8N secondsMost transient failuresLong waits for later retries
Exponential + JitterN + random(0,N), 2N + random(0,2N)Preventing thundering herdLess predictable retry timing
Linear BackoffN, 2N, 3N, 4N secondsRate-limited servicesSlower recovery than exponential
Dead Letter Queue: Tasks in the DLQ must trigger alerts. A silent DLQ fills up with tasks that nobody investigates, causing data pipelines to fall behind and reports to miss SLAs. The system sends daily digests of DLQ contents to on-call engineers and auto-escalates if DLQ size exceeds 100 tasks per day.

Error Classification

Not all errors are equal. The scheduler classifies errors to determine the appropriate response:

Error TypeExampleResponseRetry?
Transient (retryable)Network timeout, HTTP 503, rate limit 429Retry with backoffYes, up to max retries
Permanent (non-retryable)Invalid config, auth failure, HTTP 400Move to DLQ immediatelyNo
Resource exhaustionOOM, disk full, connection pool exhaustedRetry with longer delay, alert operatorYes, with 5-minute base delay
Dependency unavailableDownstream service returns 503Circuit breaker pattern, retry after probeYes, after circuit breaker opens
Timeout (ambiguous)Task exceeded timeout, unclear if it ranLog warning, retry with idempotency checkYes, only if idempotent

Retry Budget Pattern

To prevent retry storms from overwhelming the system, the scheduler implements a global retry budget. The budget limits total retries to 20% of total task executions per minute. If retries exceed the budget, new retries are deferred until the budget recovers. This prevents a cascade where transient failures cause retries that consume all worker capacity, leaving no room for normal task execution. The retry budget is enforced at the worker pool level — each worker checks the global retry ratio before accepting a retry task.

C#
public class RetryBudgetEnforcer
{
    private long _totalExecutions;
    private long _totalRetries;
    private readonly double _maxRetryRatio = 0.20;

    public bool CanRetry()
    {
        var currentRatio = (double)_totalRetries /
            Math.Max(1, _totalExecutions);
        return currentRatio < _maxRetryRatio;
    }

    public void RecordExecution(bool wasRetry)
    {
        Interlocked.Increment(ref _totalExecutions);
        if (wasRetry) Interlocked.Increment(ref _totalRetries);
    }
}

13. Task Dependency Management

Beyond DAG-level dependencies, individual tasks may depend on external conditions: a file appearing in S3, a database migration completing, or an API becoming available. The dependency manager supports event-driven triggers where a task waits for a specific event before executing. This is implemented using Kafka consumers that watch for event topics and dispatch dependent tasks when the expected event arrives.

Dependency Types

Dependency TypeHow It WorksUse Case
Data DependencyTask B reads output of Task AETL pipeline stages
Temporal DependencyTask B runs only after Task A's schedule windowNightly reports after data collection
Event DependencyTask B runs when external event arrivesDeploy after CI/CD pipeline completes
Resource DependencyTask B waits for resource to be availableGPU allocation for ML training

The dependency manager uses a two-phase approach: at DAG definition time, it validates that all dependencies are resolvable and computes a topological execution order. At runtime, it tracks the completion status of each dependency and dispatches tasks only when all their dependencies are satisfied. The dependency check is O(1) per task — just decrement an in-degree counter — making the engine efficient even for DAGs with thousands of tasks.

Circular Dependency Detection: Before accepting a DAG definition, the system must detect cycles using depth-first search (DFS) with a visited set. If a cycle is detected, reject the definition with a clear error message showing the cycle path. Runtime cycle detection is redundant if the definition was validated, but adding a simple visited set check during execution provides defense-in-depth against data corruption in the dependency graph.

14. Monitoring & Alerting

Comprehensive monitoring is essential for a task scheduler because failures are often silent — a missed task execution doesn't raise an error, it simply doesn't happen. The monitoring system must track three categories of metrics: schedule accuracy (are tasks running on time?), execution health (are tasks succeeding?), and infrastructure health (are workers and databases available?).

Key Metrics Dashboard

MetricAlert ThresholdSeverity
Task schedule delay> 5 seconds from scheduled timeWarning
Task failure rate> 5% in 15-minute windowCritical
Worker queue depth> 10,000 pending tasksWarning
DLQ size growth> 100 tasks/dayCritical
Scheduler leader failoverAny leader change eventInfo
Worker node offlineAny worker disconnectWarning
DAG execution stuck> 30 minutes with no progressCritical
Execution latency P99> 30 seconds for sub-second tasksWarning
Missed scheduled executionAny task missed by > 10 secondsCritical

Execution History Query

The execution history store supports fast queries for debugging: "Show me all failed executions for task X in the last 24 hours", "Show me the execution timeline for DAG run Y", "Show me the P95 execution latency for task Z over the last week". These queries are served by PostgreSQL with materialized views that pre-aggregate execution statistics by hour and day. For long-term analytics, data is exported to a columnar store (ClickHouse) that supports fast analytical queries over billions of rows.

Observability Best Practice: Every task execution should emit a structured log entry with: task_id, execution_id, scheduled_at, started_at, completed_at, status, duration_ms, worker_id, attempt, and any error details. These logs are indexed in Elasticsearch and retained for 90 days, enabling fast debugging of any execution failure.

Dashboard Design

The monitoring dashboard is organized into four views: (1) System Overview — total tasks, success rate, queue depth, worker count. (2) Task Detail — execution history, latency trend, failure breakdown for a specific task. (3) Worker Health — per-worker CPU, memory, active tasks, error rate. (4) DAG Execution — real-time visualization of running DAGs with color-coded task states (green=completed, yellow=running, red=failed, gray=skipped).

Runbook Automation

Common operational tasks have automated runbooks: "Task failing repeatedly" → check downstream service health → if healthy, retry manually → if unhealthy, pause task and alert service owner. "Worker pool saturated" → check if scaling is configured → if yes, wait for auto-scale → if no, manually add workers. "Scheduler leader failover" → check if PostgreSQL primary is healthy → if yes, wait for new leader → if no, initiate database failover first. These runbooks are embedded in the alerting system — each PagerDuty alert includes a link to the relevant runbook.

15. Multi-Tenancy & Isolation

A SaaS task scheduler must isolate tenants: one tenant's heavy workload should not degrade another tenant's task execution. This is the noisy neighbor problem applied to scheduling. Isolation is achieved at three levels:

  • Compute isolation: Each tenant's tasks run in dedicated worker pools with configurable concurrency limits. A tenant running 1,0,000 tasks cannot consume more than their allocated 500 concurrent worker slots. Excess tasks queue and execute as slots become available.
  • Storage isolation: Tenant data is partitioned by tenant_id in all database tables. Queries always include a tenant_id filter enforced at the ORM level, preventing accidental cross-tenant data access. For compliance requirements (GDPR, SOC 2), each tenant's data can be stored in a separate database instance.
  • Queue isolation: Each tenant has a separate Kafka topic with fair scheduling across tenants. This prevents a tenant with high task volume from starving other tenants' task dispatch.

Resource Quotas

Each tenant has configurable resource quotas that prevent runaway usage: max concurrent tasks (default 100), max tasks per hour (default 10,000), max compute minutes per month (default 50,000), and max storage for execution logs (default 100GB). Quotas are enforced at scheduling time — if a tenant exceeds their quota, new tasks are queued and executed as resources become available. Quota violations trigger alerts to the tenant's admin, not system-wide errors. This "soft enforcement" approach prevents one tenant's quota exhaustion from affecting the scheduler's overall health.

C#
public class TenantQuotaEnforcer
{
    private readonly IQuotaRepository _quotaRepo;

    public async Task<bool> CanScheduleAsync(
        Guid tenantId, TaskDefinition task)
    {
        var quota = await _quotaRepo.GetQuotaAsync(tenantId);
        var usage = await _quotaRepo.GetUsageAsync(tenantId);

        if (usage.ConcurrentTasks >= quota.MaxConcurrentTasks)
            return false; // Concurrency limit reached

        if (usage.TasksThisHour >= quota.MaxTasksPerHour)
            return false; // Rate limit reached

        if (usage.ComputeMinutesThisMonth >=
            quota.MaxComputeMinutesPerMonth)
            return false; // Budget exhausted

        return true;
    }
}

16. Distributed Locking

Distributed locks prevent concurrent workers from executing the same task simultaneously. The lock implementation uses PostgreSQL advisory locks or Redis SET NX EX with a unique owner ID and TTL. Lock renewal happens automatically every TTL/3 seconds. If a worker crashes mid-task, the lock expires after the TTL and another worker can claim the task. The lock must be reentrant — a worker renewing a lock it already holds should not deadlock.

The choice between PostgreSQL advisory locks and Redis locks depends on the use case. PostgreSQL locks are durable (survive restarts via WAL replication) but have higher latency (1-5ms). Redis locks are faster (sub-millisecond) but can be lost during Redis failover if not configured with proper persistence. For critical tasks (financial transactions, data mutations), use PostgreSQL advisory locks. For high-frequency, low-stakes locks (cache invalidation, rate limiting), Redis is preferred. The scheduler can use both — PostgreSQL for task execution locks and Redis for operational locks like leader election.

C#
public class DistributedLock
{
    private readonly IRedisCluster _redis;
    private readonly string _ownerId = Guid.NewGuid().ToString();

    public async Task<LockHandle> AcquireAsync(
        string lockKey, TimeSpan ttl)
    {
        var result = await _redis.SetAsync(
            $"lock:{lockKey}", _ownerId,
            ttl: ttl, onlyIfNotExists: true);
        if (!result) return null;
        return new LockHandle(lockKey, _ownerId, _redis, ttl);
    }
}

public class LockHandle : IAsyncDisposable
{
    private Timer _renewalTimer;
    private readonly string _key, _owner;
    private readonly IRedisCluster _redis;

    public LockHandle(string key, string owner,
        IRedisCluster redis, TimeSpan ttl)
    {
        (_key, _owner, _redis) = (key, owner, redis);
        _renewalTimer = new Timer(
            async _ => await redis.SetAsync(
                $"lock:{key}", owner, ttl: ttl),
            null, ttl / 3, ttl / 3);
    }

    public async ValueTask DisposeAsync()
    {
        _renewalTimer?.Dispose();
        // Only delete if we still own the lock
        var current = await _redis.GetAsync($"lock:{_key}");
        if (current == _owner)
            await _redis.DeleteAsync($"lock:{_key}");
    }
}

Lock Comparison

MechanismLatencyDurabilityBest For
Redis SET NX EX< 1msConfigurable (AOF persistence)High-frequency, low-latency locks
PostgreSQL Advisory Lock1-5msStrong (WAL durability)Critical locks requiring durability
ZooKeeper / etcd5-15msStrong (Raft consensus)Coordination requiring linearizability

17. Reliability & Failure Modes

FailureImpactMitigation
Scheduler leader crashesNo tasks triggered for up to 15sLeader election with 15s TTL, standby takes over
Worker crashes mid-taskTask may run twiceIdempotent task execution + lock expiry
PostgreSQL downCannot update task stateRedis serves as write-behind buffer, tasks retry after DB recovery
Kafka partition unavailableTasks in that partition delayedReplication factor 3, consumer group rebalancing
Clock skew across schedulersTasks fire early or lateNTP sync, HLC, 2-second evaluation window
Task execution timeoutHung task blocks workerProcess kill on timeout, worker reports timeout status

Disaster Recovery

In a complete region failure, the scheduler must recover within 5 minutes with zero missed critical tasks. The recovery process: (1) The standby scheduler in the DR region acquires leadership within 15 seconds. (2) It loads the task schedule from the DR PostgreSQL replica (promoted to primary). (3) It evaluates all tasks with a 5-minute lookahead window, dispatching any tasks that would have been missed during the outage. (4) Workers in the DR region pick up pending tasks from the replicated Kafka topic.

The key insight is that the scheduler's state is fully durably stored — PostgreSQL holds task definitions and execution history, Kafka holds pending task messages — so recovery is a matter of loading state and resuming evaluation. The RPO (Recovery Point Objective) is zero — no task definitions are lost because they are durably stored. The RTO (Recovery Time Objective) is 5 minutes — the time to promote the DR database, elect a new leader, and start evaluating tasks.

The Silent Failure Problem: The most dangerous scheduler failure is not a crash — it is a silent failure where the scheduler runs but doesn't trigger tasks. This can happen due to: clock drift causing the cron evaluator to skip tasks, a database connection pool exhaustion causing task status checks to fail silently, or a configuration error that disables a task definition. The defense is a "watchdog" service that independently monitors task execution rates and alerts if the expected number of executions doesn't match reality. The watchdog compares actual executions per hour against expected executions per hour (derived from the task schedule) and triggers an alert if the ratio drops below 95%.

18. Cost Estimation

ComponentMonthly CostNotes
Scheduler nodes (3x m5.xlarge)$600Leader + 2 standbys
Worker pool (100-500 auto-scaled)$15,000Varies with load
PostgreSQL cluster$3,0003 nodes + read replicas
Redis cluster$1,500Status cache + locks
Kafka cluster$2,000Task dispatch queue
S3 (logs + archives)$50090-day retention
Monitoring (Prometheus + Grafana)$1,000Metrics + alerting
Total~$23,600

Cost Optimization Strategies

Worker nodes represent 63% of total infrastructure cost. Three strategies reduce this significantly: (1) Spot Instances: Use AWS spot instances or GCP preemptible VMs for bulk-tier workers, reducing their cost by 60-70%. The scheduler handles preemption gracefully by checkpointing task progress and re-dispatching to new workers. (2) Right-sizing: Profile actual CPU/memory usage of task handlers and select the smallest instance type that provides adequate headroom. Many tasks are I/O-bound and can run on smaller, cheaper instances. (3) Scheduled Scaling: Scale down the worker pool during off-peak hours (nights, weekends) when task volume drops by 80%. This can reduce average monthly worker cost by 40%.

OptimizationSavingsImpact
Spot instances for bulk tier~$6,000/month40% of workers, graceful preemption
Off-peak scaling~$4,500/month80% volume drop at night
Right-sizing instances~$2,000/monthProfile-driven, no reliability impact
Optimized Total~$11,100/month53% reduction

19. Codebase Structure

The task scheduler is organized as a .NET 8+ solution with clearly separated projects following Clean Architecture principles. Domain logic sits at the center with zero infrastructure dependencies, while concrete implementations for PostgreSQL, Redis, and Kafka live in the infrastructure layer. This separation enables fast unit tests for domain logic and comprehensive integration tests for infrastructure.

Project Structure
DistributedTaskScheduler/
├── src/
│   ├── DTS.Domain/                  # Core domain models and interfaces
│   │   ├── Models/
│   │   │   ├── TaskDefinition.cs
│   │   │   ├── TaskExecution.cs
│   │   │   ├── ExecutionStatus.cs
│   │   │   └── DAGDefinition.cs
│   │   ├── Enums/
│   │   │   ├── RetryPolicy.cs
│   │   │   └── TaskPriority.cs
│   │   └── Interfaces/
│   │       ├── ITaskRepository.cs
│   │       ├── IExecutionRepository.cs
│   │       └── ITaskDispatcher.cs
│   │
│   ├── DTS.Scheduler/              # Scheduler service (leader election, cron evaluator)
│   │   ├── Services/
│   │   │   ├── CronEvaluator.cs
│   │   │   ├── LeaderElectionService.cs
│   │   │   └── TaskSchedulerHost.cs
│   │   └── DTS.Scheduler.csproj
│   │
│   ├── DTS.Worker/                 # Worker pool (task execution)
│   │   ├── Execution/
│   │   │   ├── ITaskHandler.cs
│   │   │   ├── TaskExecutionContext.cs
│   │   │   └── WorkerPoolManager.cs
│   │   ├── Health/
│   │   │   └── WorkerHealthCheck.cs
│   │   └── DTS.Worker.csproj
│   │
│   ├── DTS.DAG/                    # DAG execution engine
│   │   ├── Services/
│   │   │   ├── DAGExecutor.cs
│   │   │   ├── TopologicalSorter.cs
│   │   │   └── DAGValidator.cs
│   │   └── DTS.DAG.csproj
│   │
│   ├── DTS.Infrastructure/         # Database, Redis, Kafka implementations
│   │   ├── Persistence/
│   │   │   ├── PostgresTaskRepository.cs
│   │   │   ├── PostgresExecutionRepository.cs
│   │   │   └── PostgresDbContext.cs
│   │   ├── Messaging/
│   │   │   ├── KafkaTaskDispatcher.cs
│   │   │   └── KafkaConsumer.cs
│   │   ├── Cache/
│   │   │   └── RedisLockProvider.cs
│   │   └── DTS.Infrastructure.csproj
│   │
│   └── DTS.Api/                    # REST API gateway
│       ├── Controllers/
│       │   ├── TasksController.cs
│       │   ├── ExecutionsController.cs
│       │   └── HealthController.cs
│       ├── Middleware/
│       │   └── TenantContextMiddleware.cs
│       └── DTS.Api.csproj
│
├── tests/
│   ├── DTS.UnitTests/
│   ├── DTS.IntegrationTests/
│   └── DTS.LoadTests/
│
├── Dockerfile
└── docker-compose.yml

Key Dependencies

C#
// DTS.Domain.csproj - No external dependencies
// Pure domain models and interfaces only

// DTS.Infrastructure.csproj
<ItemGroup>
  <PackageReference Include="Npgsql" Version="8.0.3" />
  <PackageReference Include="StackExchange.Redis" Version="2.7.15" />
  <PackageReference Include="Confluent.Kafka" Version="2.3.0" />
  <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
  <PackageReference Include="Serilog" Version="3.1.1" />
  <PackageReference Include="Polly" Version="8.3.0" />
</ItemGroup>

// DTS.Scheduler.csproj
<ItemGroup>
  <PackageReference Include="Cronos" Version="0.8.4" />
  <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
Clean Architecture Benefit: Domain logic has zero infrastructure dependencies — it only references interfaces. This means unit tests run in milliseconds with no database, Kafka, or Redis. Integration tests verify infrastructure implementations against real services in Docker Compose. Load tests stress the full stack end-to-end. Each layer can be replaced independently — switch from PostgreSQL to CockroachDB without touching domain logic. New team members can understand the business rules by reading the Domain project alone, without wading through infrastructure code.

20. Testing Strategy

The testing strategy for a distributed task scheduler must cover three levels: correctness of individual components, integration across components, and reliability under failure. The system's correctness properties are subtle — a missed task or double execution can have cascading effects — making thorough testing essential for production confidence.

Unit Tests

C#
[TestClass]
public class CronEvaluatorTests
{
    private readonly CronEvaluator _evaluator = new();

    [TestMethod]
    public void Should_Evaluate_Daily_Cron()
    {
        var expr = CronExpression.Parse("30 2 * * *");
        var now = new DateTime(2025, 7, 15, 1, 0, 0, DateTimeKind.Utc);
        var next = expr.GetNextOccurrence(now);
        Assert.AreEqual(new DateTime(2025, 7, 15, 2, 30, 0, DateTimeKind.Utc), next);
    }

    [TestMethod]
    public void Should_Handle_Month_End_Dates()
    {
        var expr = CronExpression.Parse("0 0 L * *");
        var now = new DateTime(2025, 1, 31, 12, 0, 0, DateTimeKind.Utc);
        var next = expr.GetNextOccurrence(now);
        Assert.AreEqual(new DateTime(2025, 2, 28, 0, 0, 0, DateTimeKind.Utc), next);
    }

    [TestMethod]
    public void Should_Deduplicate_Within_Window()
    {
        var executor = new Mock<ITaskExecutor>();
        var evaluator = new CronEvaluator(executor.Object, TimeSpan.FromSeconds(2));
        var scheduledTime = DateTime.UtcNow;
        var scheduledTime2 = scheduledTime.AddMilliseconds(500);

        evaluator.EvaluateTaskAsync("task-1", "0 * * * *", scheduledTime).Wait();
        evaluator.EvaluateTaskAsync("task-1", "0 * * * *", scheduledTime2).Wait();

        executor.Verify(x => x.ExecuteAsync(It.IsAny<TaskExecution>()), Times.Once);
    }
}

Integration Tests

C#
[TestClass]
public class EndToEndTests : IAsyncLifetime
{
    private DockerComposeFixture _docker;
    private HttpClient _api;

    public async Task InitializeAsync()
    {
        _docker = new DockerComposeFixture("docker-compose.test.yml");
        await _docker.StartAsync();
        _api = new HttpClient { BaseAddress = new Uri("http://localhost:5001") };
    }

    [TestMethod]
    public async Task Should_Execute_Scheduled_Task_End_To_End()
    {
        var response = await _api.PostAsJsonAsync("/api/tasks", new
        {
            Name = "test-integration",
            ScheduleType = "once",
            ScheduledAt = DateTime.UtcNow.AddSeconds(5),
            HandlerType = "TestHandler"
        });
        Assert.AreEqual(HttpStatusCode.Created, response.StatusCode);

        await Task.Delay(TimeSpan.FromSeconds(10));

        var execResponse = await _api.GetAsync("/api/tasks/test-integration/executions");
        var executions = await execResponse.Content
            .ReadFromJsonAsync<List<TaskExecutionDto>>();
        Assert.AreEqual(1, executions.Count);
        Assert.AreEqual("completed", executions[0].Status);
    }

    public async Task DisposeAsync() => await _docker.StopAsync();
}

Chaos Engineering Tests

The most critical tests verify system behavior under failure. These are run weekly in a staging environment that mirrors production. The chaos tests include: killing a scheduler leader mid-evaluation (verifies failover within 15 seconds with zero missed tasks), terminating a worker while it executes a task (verifies automatic re-queue and completion by another worker), partitioning the PostgreSQL primary from its replica (verifies automatic failover and task resume), and dropping 50% of Kafka messages randomly (verifies eventual delivery through retries). Each test validates specific recovery time and zero-task-loss guarantees.

Load Testing

Test ScenarioTargetSuccess Criteria
Schedule 100K tasks/hour100,000All tasks triggered within 1s of scheduled time
Execute 10K concurrent tasks10,00099th percentile latency < 5 seconds
Worker pool scaling10 → 500 workersScale from 10 to 500 in < 5 minutes
Database failoverPrimary downZero missed tasks during 30-second failover
Kafka broker failure1 of 6 brokers downAll tasks dispatched within 10 seconds
Test Coverage Targets: Domain logic (CronEvaluator, DAGExecutor, TopologicalSorter) requires 95%+ unit test coverage. Infrastructure implementations (PostgresTaskRepository, KafkaDispatcher) require 80%+ integration test coverage with real databases and message brokers. Load tests must verify the system handles 100K tasks/hour with less than 1 second schedule delay at P99.

22. Security Considerations

A task scheduler executes arbitrary code on your infrastructure — making it a high-value attack target. If an attacker compromises the scheduler, they can execute arbitrary commands on worker nodes, exfiltrate data from task parameters, or disrupt critical business processes. Security must be designed in from the start, not bolted on after a breach.

Threat Model

ThreatAttack VectorImpactMitigation
Task injectionMalicious API requestArbitrary code executionInput validation, command allowlist, container isolation
Credential theftTask parameter inspectionSecrets exposureSecret references (not values), encrypted at rest, short-lived tokens
Privilege escalationTenant escapeCross-tenant data accessRow-level security, tenant_id enforcement in all queries
DoS via resource exhaustionRecursive DAGs, infinite loopsWorker pool exhaustionMax DAG depth, task timeouts, resource quotas per tenant
Log injectionMalicious task outputLog poisoning, XSS in dashboardsSanitize task output before logging, escape HTML in UI

Secret Management

Task definitions never contain plaintext secrets. Instead, they reference secrets by name: "DATABASE_URL": "{{secret:sales-db-url}}". At execution time, the worker resolves the reference by fetching the secret from a vault (HashiCorp Vault, AWS Secrets Manager). Secrets are injected as environment variables and never written to disk. The worker process runs with minimal permissions — it can only access secrets for its own tenant.

C#
public class SecretResolver
{
    private readonly ISecretVault _vault;
    private readonly IMemoryCache _cache;

    public async Task<Dictionary<string, string>>
        ResolveSecretsAsync(
            Guid tenantId,
            Dictionary<string, string> parameters)
    {
        var resolved = new Dictionary<string, string>();
        foreach (var (key, value) in parameters)
        {
            if (value.StartsWith("{{secret:"))
            {
                var secretName = value
                    .Trim('{', '}')
                    .Replace("secret:", "");
                var cacheKey = $"{tenantId}:{secretName}";
                var secret = await _cache.GetOrCreateAsync(
                    cacheKey,
                    async _ => await _vault
                        .GetSecretAsync(tenantId, secretName));
                resolved[key] = secret.Value;
            }
            else
            {
                resolved[key] = value;
            }
        }
        return resolved;
    }
}

Container Sandboxing

All tasks execute in containers with strict resource limits: CPU (max 4 cores), memory (max 8GB), network (deny-all by default, allow-list specific endpoints), and filesystem (read-only root, writable tmpfs for scratch). Containers run as non-root users with seccomp profiles that block dangerous syscalls. Network policies prevent containers from accessing the scheduler's internal APIs or other tenants' services. After execution, the container is destroyed — no persistent state survives between executions.

Audit Logging: Every API call, task creation, execution, and administrative action is logged with: who (authenticated user), what (action), when (timestamp), where (IP address), and result (success/failure). Audit logs are append-only, stored in a separate immutable log store (S3 with Object Lock), and retained for 7 years. These logs are essential for compliance (SOC 2, GDPR) and incident response.

21. Interview Q&A Deep Dive

Q1: How do you prevent a task from being executed twice?

Answer: Use an atomic claim operation: the worker performs UPDATE with WHERE status = 'pending'. Only one worker's UPDATE succeeds (returns affected rows = 1). This is backed by PostgreSQL row-level locking. Additionally, tasks should be designed to be idempotent — even if executed twice, the result should be the same. For non-idempotent tasks (like sending an email), use a unique idempotency key per scheduled execution to deduplicate. The idempotency key is the combination of task_id + scheduled_at, which uniquely identifies each execution. The task handler checks if an execution with this key already completed successfully before starting work.

Q2: How do you handle scheduler leader election failure?

Answer: The leader election uses a distributed lock with a 15-second TTL. If the leader crashes, the lock expires and a standby acquires it within 15 seconds. During the gap, no tasks are evaluated — this is acceptable because the scheduler evaluates with a 2-second lookahead window. Tasks that were due during the gap are still caught by the new leader's first evaluation. For zero-downtime, use a standby that pre-caches the task schedule. The standby maintains a read-only connection to the task database and keeps a local copy of the schedule. When it acquires leadership, it can start evaluating immediately without a database read. The warm standby approach reduces failover time from 15 seconds to under 1 second.

Q3: How do you handle a worker that keeps failing the same task?

Answer: After exhausting retries (typically 3-5), the task moves to the Dead Letter Queue (DLQ). Operators investigate, fix the root cause, and manually retry. To prevent DLQ pollution from systemic issues, the system detects patterns (e.g., "all tasks to service X are failing") and auto-disables affected task definitions with a notification. The DLQ also supports bulk retry — operators can select multiple tasks and retry them all at once. Each DLQ entry includes the full execution context: the task definition, the execution parameters, the error message and stack trace, the number of retries attempted, and the timestamps of each attempt. This context is essential for debugging.

Q4: How do you scale the scheduler to handle 100K tasks per hour?

Answer: Partition tasks by task_id across multiple scheduler instances using consistent hashing. Each instance is responsible for evaluating a subset of tasks. Kafka partitions the task dispatch topic by task_id, ensuring each worker only processes tasks from its assigned partition. The scheduler instances coordinate through shared state in PostgreSQL (task definitions) and Redis (execution locks). This horizontal scaling approach supports millions of tasks per hour. The key insight is that each scheduler instance only needs to evaluate its own partition of tasks, so adding instances linearly increases throughput. With 10 scheduler instances, each handling 10K tasks/hour, the system handles 100K total.

Q5: How do you handle task dependencies that form a cycle?

Answer: Use topological sort at definition time to detect cycles. If a cycle is detected, reject the DAG definition with a clear error. At runtime, detect cycles using DFS with a visited set. If a cycle is detected (which should be impossible with validated definitions), mark the DAG as failed and alert. The defense-in-depth approach prevents both user errors and data corruption. The cycle detection runs in O(V+E) time where V is the number of tasks and E is the number of dependencies. For most DAGs, this is under 100ms. The validator also checks for orphaned tasks (tasks with no incoming or outgoing edges that might indicate a typo in dependency names).

Q6: How do you handle clock skew in a distributed scheduler?

Answer: Clock skew can cause duplicate triggers or missed tasks. Solutions: (1) NTP synchronize all nodes to within 500ms. (2) Use a 2-second evaluation window — evaluate tasks whose scheduled time is within the next 2 seconds, not exactly at the current second. This tolerance absorbs small clock differences. (3) Use idempotent task execution — even if triggered twice, the task produces the same result. (4) For critical tasks, use hybrid logical clocks (HLC) that combine physical time with a logical counter for monotonic ordering. The HLC assigns a timestamp that is always greater than any previous timestamp, even if the physical clock goes backward. This prevents the "time went backward, so let's re-trigger everything" problem.

Key Numbers to Remember

MetricValue
Schedule accuracy targetWithin 1 second of scheduled time
Leader election failover15 seconds (lock TTL)
Warm standby failover< 1 second
Worker heartbeat interval30 seconds
Task claim timeout2 minutes (heartbeat expiry)
Worker claim operationAtomic CAS (pending → running)
Max concurrent tasks per worker20-100 (tier-dependent)
DAG evaluation latencyO(1) per task completion (decrement in-degree)
Cycle detection complexityO(V+E) where V=tasks, E=dependencies
Retry backoff maximumExponential up to 1 hour
Monthly infrastructure cost~$26,100

Pre-Interview Checklist

  • Understand leader election and its failure modes (cold vs warm standby)
  • Know at-least-once vs exactly-once semantics and idempotency
  • Design a DAG executor with topological sort
  • Understand cron parsing and efficient evaluation with 2-second window
  • Know retry policies (fixed, exponential, jitter) and when to use each
  • Discuss multi-tenancy isolation strategies (compute, storage, queue)
  • Understand distributed locking and failure modes (Redis vs PostgreSQL vs ZooKeeper)
  • Explain the silent failure problem and watchdog mitigation
  • Know how to handle clock skew with HLC and evaluation windows
  • Understand the DLQ pattern and bulk retry capabilities

22. Scheduler Monitoring and Alerting Dashboard

Comprehensive monitoring of a distributed task scheduler requires tracking job execution success rates, queue depth, worker health, and SLA compliance. The monitoring system must detect anomalies like silent failures, growing backlogs, and worker imbalances before they impact SLAs.

public class SchedulerHealthMonitor
{
    private readonly IMetricsCollector _metrics;
    private readonly IAlertingService _alerting;

    public async Task<SchedulerHealthReport> GetHealthReportAsync()
    {
        var report = new SchedulerHealthReport();

        report.QueueDepth = await _metrics.GetGaugeAsync("scheduler.queue_depth");
        report.ActiveWorkers = await _metrics.GetGaugeAsync("scheduler.active_workers");
        report.SuccessRate = await _metrics.GetGaugeAsync("scheduler.success_rate_1h");
        report.AvgExecutionTime = await _metrics.GetGaugeAsync("scheduler.avg_execution_ms");
        report.ScheduledJobsPerMinute = await _metrics.GetCounterAsync("scheduler.scheduled_1m");

        if (report.QueueDepth > 10_000)
        {
            await _alerting.SendAlertAsync(new Alert
            {
                Severity = AlertSeverity.Critical,
                Title = "High scheduler queue depth",
                Message = $"Queue depth: {report.QueueDepth:N0}"
            });
        }

        if (report.SuccessRate < 0.95)
        {
            await _alerting.SendAlertAsync(new Alert
            {
                Severity = AlertSeverity.Warning,
                Title = "Low task success rate",
                Message = $"Success rate: {report.SuccessRate:P1}"
            });
        }

        return report;
    }
}

Key Scheduler Metrics

MetricTargetAlert Threshold
Task Success Rate> 99.5%< 95%
Schedule AccuracyWithin 1 second> 5 seconds late
Queue Depth< 1,000> 10,000
Worker Utilization60-80%< 30% or > 90%
DLQ Depth< 50> 200
Leader Election Uptime99.99%Failover > 1 per day

Distributed Task Scheduler — Senior+ Guide | Ayodhyya