How to Design a Distributed Task Scheduler
Building a Production-Grade Cron-as-a-Service — Scheduling, Execution, Reliability
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.
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:
| Company | System | Scale | Key Innovation |
|---|---|---|---|
| Apache Airflow (Airbnb) | DAG orchestrator | 150M task runs/month | Pull-based worker model, dynamic DAG generation |
| GitHub Actions | CI/CD workflow engine | Billions of runs/year | Container-native execution, marketplace of reusable actions |
| Temporal | Durable execution engine | Millions of workflows/day | Event sourcing for replay, code-as-workflow (not config) |
| Cadence (Uber) | Workflow orchestration | Billions of executions/year | Mutable state, side-effect capture, sticky execution |
| Apache DolphinScheduler | Enterprise scheduler | 10K+ nodes | Multi-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
- Task Definition: Users can define tasks with cron expressions, interval schedules, or one-shot delayed execution. Tasks can have parameters, timeouts, and retry policies.
- 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.
- Task Execution: Tasks execute on worker nodes with resource isolation. Workers report status (success, failure, progress) back to the scheduler.
- Retry & Error Handling: Failed tasks retry with configurable backoff strategies (fixed, exponential, jitter). Tasks that exhaust retries go to a dead letter queue.
- Monitoring & Observability: Real-time dashboards showing task status, execution history, latency percentiles, and failure rates. Alerting on missed schedules and elevated error rates.
- Manual Controls: Users can trigger tasks immediately, pause/resume schedules, kill running tasks, and view detailed execution logs.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Schedule Accuracy | Within 1 second of scheduled time | Financial tasks and data pipelines depend on precise timing |
| Availability | 99.99% | Task scheduling is infrastructure — downtime cascades |
| Task Throughput | 100K tasks/hour | Enterprise-scale scheduling load |
| Exactly-Once Execution | At-least-once with idempotent tasks | Prevent duplicate charges, duplicate reports |
| Execution Latency | < 5 seconds from scheduled time | Tasks should not be significantly delayed |
| History Retention | 90 days detailed, 1 year aggregate | Audit and debugging requirements |
| Multi-Tenancy | Resource isolation per customer | SaaS scheduling platform must isolate tenants |
| Failover Time | < 15 seconds (leader), < 2 minutes (worker) | Minimize disruption during failures |
| Recovery Point Objective | Zero missed tasks | Every scheduled task must eventually execute |
| API Rate Limit | 1000 req/min per tenant | Protect scheduler from API abuse |
Key Design Tradeoffs
| Tradeoff | Option A | Option B | Our Choice |
|---|---|---|---|
| Push vs Pull dispatch | Scheduler pushes tasks to workers (lower latency) | Workers pull tasks (better backpressure) | Pull via Kafka (backpressure + decoupling) |
| Centralized vs Distributed state | Single source of truth in DB (simpler) | Replicated state across nodes (faster) | Centralized DB + Redis cache (simplicity wins) |
| Event-driven vs Polling evaluation | Event-driven (lower latency, complex) | Time-based polling (simpler, predictable) | Polling with 1s interval (simplicity, predictable) |
| Exactly-once vs At-least-once | Exactly-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
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
| Data | Storage | Rationale |
|---|---|---|
| Task definitions | PostgreSQL (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 history | PostgreSQL (90 days) → S3 (archive) | Hot history for debugging, cold for compliance |
| Execution logs | S3 + CloudWatch/ELK | Large blobs, not suitable for database |
| DAG state | PostgreSQL (relational) | Graph queries for dependency traversal |
| Real-time queue | Kafka / Redis Streams | High-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.
Request Flow: Scheduled Task Execution
- 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.
- 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.
- 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.
- 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)
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
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Scan All | Check all task definitions every second | Simple | O(n) per second, doesn't scale |
| Priority Queue | Heap ordered by next execution time | O(log n) to find due tasks | Must re-insert after each execution |
| Time-Bucketed | Hash tasks into 1-minute buckets | O(1) to find due tasks | Boundary 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);
}
}
}
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.
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).
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.
| Tier | Concurrency | Auto-Scale Trigger | Max Nodes |
|---|---|---|---|
| Critical | 100 concurrent tasks/node | Queue depth > 500 | 50 |
| Normal | 50 concurrent tasks/node | Queue depth > 1000 | 200 |
| Bulk | 20 concurrent tasks/node | Queue depth > 5000 | 100 |
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.
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
| Policy | Behavior | Use Case |
|---|---|---|
| FailAll | Cancel all remaining tasks in the DAG | Financial pipelines where partial results are dangerous |
| SkipDownstream | Skip tasks that depend on the failed task, continue others | Data pipelines that can produce partial results |
| ContinueAll | Run all tasks regardless of upstream failures | Independent 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 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
| Policy | Behavior | Use Case |
|---|---|---|
| All-or-Nothing | Any task failure fails the entire DAG | Critical financial workflows |
| Skip Downstream | Failed task skips its downstream tasks, rest continues | Data pipelines with partial results |
| Best Effort | All tasks run regardless of upstream failures | Independent reporting tasks |
| Conditional Branch | Task output determines which downstream branch runs | ML pipelines (deploy if accuracy > threshold) |
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
};
}
}
| Policy | Pattern | Best For | Risk |
|---|---|---|---|
| Fixed | Retry every N seconds | Consistent transient failures | Can overwhelm recovering service |
| Exponential Backoff | N, 2N, 4N, 8N seconds | Most transient failures | Long waits for later retries |
| Exponential + Jitter | N + random(0,N), 2N + random(0,2N) | Preventing thundering herd | Less predictable retry timing |
| Linear Backoff | N, 2N, 3N, 4N seconds | Rate-limited services | Slower recovery than exponential |
Error Classification
Not all errors are equal. The scheduler classifies errors to determine the appropriate response:
| Error Type | Example | Response | Retry? |
|---|---|---|---|
| Transient (retryable) | Network timeout, HTTP 503, rate limit 429 | Retry with backoff | Yes, up to max retries |
| Permanent (non-retryable) | Invalid config, auth failure, HTTP 400 | Move to DLQ immediately | No |
| Resource exhaustion | OOM, disk full, connection pool exhausted | Retry with longer delay, alert operator | Yes, with 5-minute base delay |
| Dependency unavailable | Downstream service returns 503 | Circuit breaker pattern, retry after probe | Yes, after circuit breaker opens |
| Timeout (ambiguous) | Task exceeded timeout, unclear if it ran | Log warning, retry with idempotency check | Yes, 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 Type | How It Works | Use Case |
|---|---|---|
| Data Dependency | Task B reads output of Task A | ETL pipeline stages |
| Temporal Dependency | Task B runs only after Task A's schedule window | Nightly reports after data collection |
| Event Dependency | Task B runs when external event arrives | Deploy after CI/CD pipeline completes |
| Resource Dependency | Task B waits for resource to be available | GPU 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.
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
| Metric | Alert Threshold | Severity |
|---|---|---|
| Task schedule delay | > 5 seconds from scheduled time | Warning |
| Task failure rate | > 5% in 15-minute window | Critical |
| Worker queue depth | > 10,000 pending tasks | Warning |
| DLQ size growth | > 100 tasks/day | Critical |
| Scheduler leader failover | Any leader change event | Info |
| Worker node offline | Any worker disconnect | Warning |
| DAG execution stuck | > 30 minutes with no progress | Critical |
| Execution latency P99 | > 30 seconds for sub-second tasks | Warning |
| Missed scheduled execution | Any task missed by > 10 seconds | Critical |
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.
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
| Mechanism | Latency | Durability | Best For |
|---|---|---|---|
| Redis SET NX EX | < 1ms | Configurable (AOF persistence) | High-frequency, low-latency locks |
| PostgreSQL Advisory Lock | 1-5ms | Strong (WAL durability) | Critical locks requiring durability |
| ZooKeeper / etcd | 5-15ms | Strong (Raft consensus) | Coordination requiring linearizability |
17. Reliability & Failure Modes
| Failure | Impact | Mitigation |
|---|---|---|
| Scheduler leader crashes | No tasks triggered for up to 15s | Leader election with 15s TTL, standby takes over |
| Worker crashes mid-task | Task may run twice | Idempotent task execution + lock expiry |
| PostgreSQL down | Cannot update task state | Redis serves as write-behind buffer, tasks retry after DB recovery |
| Kafka partition unavailable | Tasks in that partition delayed | Replication factor 3, consumer group rebalancing |
| Clock skew across schedulers | Tasks fire early or late | NTP sync, HLC, 2-second evaluation window |
| Task execution timeout | Hung task blocks worker | Process 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.
18. Cost Estimation
| Component | Monthly Cost | Notes |
|---|---|---|
| Scheduler nodes (3x m5.xlarge) | $600 | Leader + 2 standbys |
| Worker pool (100-500 auto-scaled) | $15,000 | Varies with load |
| PostgreSQL cluster | $3,000 | 3 nodes + read replicas |
| Redis cluster | $1,500 | Status cache + locks |
| Kafka cluster | $2,000 | Task dispatch queue |
| S3 (logs + archives) | $500 | 90-day retention |
| Monitoring (Prometheus + Grafana) | $1,000 | Metrics + 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%.
| Optimization | Savings | Impact |
|---|---|---|
| Spot instances for bulk tier | ~$6,000/month | 40% of workers, graceful preemption |
| Off-peak scaling | ~$4,500/month | 80% volume drop at night |
| Right-sizing instances | ~$2,000/month | Profile-driven, no reliability impact |
| Optimized Total | ~$11,100/month | 53% 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>
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 Scenario | Target | Success Criteria |
|---|---|---|
| Schedule 100K tasks/hour | 100,000 | All tasks triggered within 1s of scheduled time |
| Execute 10K concurrent tasks | 10,000 | 99th percentile latency < 5 seconds |
| Worker pool scaling | 10 → 500 workers | Scale from 10 to 500 in < 5 minutes |
| Database failover | Primary down | Zero missed tasks during 30-second failover |
| Kafka broker failure | 1 of 6 brokers down | All tasks dispatched within 10 seconds |
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
| Threat | Attack Vector | Impact | Mitigation |
|---|---|---|---|
| Task injection | Malicious API request | Arbitrary code execution | Input validation, command allowlist, container isolation |
| Credential theft | Task parameter inspection | Secrets exposure | Secret references (not values), encrypted at rest, short-lived tokens |
| Privilege escalation | Tenant escape | Cross-tenant data access | Row-level security, tenant_id enforcement in all queries |
| DoS via resource exhaustion | Recursive DAGs, infinite loops | Worker pool exhaustion | Max DAG depth, task timeouts, resource quotas per tenant |
| Log injection | Malicious task output | Log poisoning, XSS in dashboards | Sanitize 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.
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
| Metric | Value |
|---|---|
| Schedule accuracy target | Within 1 second of scheduled time |
| Leader election failover | 15 seconds (lock TTL) |
| Warm standby failover | < 1 second |
| Worker heartbeat interval | 30 seconds |
| Task claim timeout | 2 minutes (heartbeat expiry) |
| Worker claim operation | Atomic CAS (pending → running) |
| Max concurrent tasks per worker | 20-100 (tier-dependent) |
| DAG evaluation latency | O(1) per task completion (decrement in-degree) |
| Cycle detection complexity | O(V+E) where V=tasks, E=dependencies |
| Retry backoff maximum | Exponential 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
| Metric | Target | Alert Threshold |
|---|---|---|
| Task Success Rate | > 99.5% | < 95% |
| Schedule Accuracy | Within 1 second | > 5 seconds late |
| Queue Depth | < 1,000 | > 10,000 |
| Worker Utilization | 60-80% | < 30% or > 90% |
| DLQ Depth | < 50 | > 200 |
| Leader Election Uptime | 99.99% | Failover > 1 per day |