A robust cron scheduler system that handles job scheduling, execution, and failover in distributed environments with high availability and fault tolerance requirements.
1. Problem Statement & Requirements
Clarifying questions
- What scale expectations are there for the scheduler? Will we be handling millions of scheduled jobs or just hundreds?
- What consistency requirements exist? Should jobs execute exactly once, or is "at least once" acceptable?
- What are the key entities and their relationships? Is there a hierarchy of jobs, job groups, or triggers?
- What's the read/write ratio for job data? Is it mostly reads (monitoring) or writes (creation)?
- What latency sensitivity is expected? Are jobs time-critical (e.g., sub-second) or more tolerant (e.g., hourly)?
- What authentication and authorization model is required? Will there be role-based access to job creation/execution?
- Are there external integrations or dependencies? Will jobs trigger external APIs, send notifications, or integrate with other systems?
Functional requirements
- Create, read, update, and delete scheduled jobs
- Schedule jobs with various cron expressions (e.g.,
0 0 * * *for daily at midnight) - Execute jobs at scheduled times with retry logic
- Pause/resume individual jobs or job groups
- View job execution history and logs
- Configure job-specific settings like timeout, retry count, and concurrency limits
- Support for job dependencies (e.g., Job B waits for Job A to complete)
- Trigger jobs manually on demand
- Job clustering support (multiple nodes can execute jobs in a distributed setup)
Non-functional requirements
- Latency targets:
- p50: <50ms for job creation and retrieval
- p95: <200ms for job creation and retrieval
- p99: <500ms for job creation and retrieval
- Throughput: Support 10,000+ jobs per minute for creation and execution
- Availability: 99.9% uptime with graceful degradation during outages
- Reliability: At-least-once execution guarantee for jobs (with deduplication)
- Scalability: Support horizontal scaling across multiple nodes
Constraints and assumptions
- Assumption: Jobs are time-based and not event-driven (e.g., no Kafka or webhook triggers)
- Assumption: Jobs are idempotent or designed to be idempotent (to support retries)
- Constraint: System must handle job scheduling with sub-second precision
- Constraint: No external dependencies for core scheduling logic (e.g., no reliance on third-party cron libraries)
- Assumption: Jobs are not expected to run longer than 30 minutes (to prevent long-running job locks)
- Assumption: Job data is not highly sensitive or PII (can be stored in clear text for simplicity)
Out of scope
- Event-driven job triggers (e.g., Kafka or webhook-based job scheduling)
- Complex job dependencies (e.g., DAGs or complex workflows beyond simple wait-for)
- Job parameterization with dynamic values (e.g., passing runtime parameters to jobs)
- UI dashboard for job monitoring and management
- Job versioning or rollback capabilities
- External job execution (e.g., running jobs on Kubernetes pods or external services)
- Job prioritization or queuing strategies beyond cron timing
- Multi-tenant support with isolated job namespaces
2. API Design
API Style Selection
gRPC with Protocol Buffers is selected for the Cron Scheduler API due to:
- Strong typing and automatic code generation
- Efficient binary serialization with smaller payloads
- First-class support for streaming and bidirectional communication
- Built-in support for versioning through proto package management
- Better performance characteristics for high-throughput scheduling operations
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| CreateSchedule | POST /v1/schedules | Creates a new scheduled job | JWT + RBAC |
| GetSchedule | GET /v1/schedules/{id} | Retrieves a specific schedule by ID | JWT + RBAC |
| UpdateSchedule | PUT /v1/schedules/{id} | Updates an existing schedule | JWT + RBAC |
| DeleteSchedule | DELETE /v1/schedules/{id} | Deletes a schedule by ID | JWT + RBAC |
| ListSchedules | GET /v1/schedules | Lists all schedules with pagination | JWT + RBAC |
| TriggerSchedule | POST /v1/schedules/{id}/trigger | Manually triggers a schedule | JWT + RBAC |
| GetScheduleHistory | GET /v1/schedules/{id}/history | Gets execution history for a schedule | JWT + RBAC |
Request and Response Schemas
CreateScheduleRequest
{
"name": "string",
"description": "string",
"cronExpression": "string",
"targetType": "string",
"targetId": "string",
"enabled": "boolean",
"timezone": "string"
}
ScheduleResponse
{
"id": "string",
"name": "string",
"description": "string",
"cronExpression": "string",
"targetType": "string",
"targetId": "string",
"enabled": "boolean",
"timezone": "string",
"createdAt": "timestamp",
"updatedAt": "timestamp",
"nextRunAt": "timestamp"
}
HTTP Status Codes and Error Format
Standard HTTP Statuses:
- 200 OK - Successful GET, PUT, PATCH
- 201 Created - Successful POST
- 204 No Content - Successful DELETE
- 400 Bad Request - Invalid input
- 401 Unauthorized - Missing or invalid auth
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limited
- 500 Internal Server Error - Server error
Standardized Error Response:
{
"error": {
"code": "string",
"message": "string",
"details": [
{
"field": "string",
"message": "string"
}
]
}
}
API Versioning Strategy
Versioned using gRPC package naming:
cron.v1- Current stable version- Future:
cron.v2, etc.
Pagination Design
Cursor-based pagination selected because:
- More efficient than offset-based for large datasets
- Consistent results even with concurrent modifications
- Better performance characteristics for large datasets
Authentication and Authorization
Authentication: JWT Bearer tokens with OAuth2 integration Authorization: RBAC model with roles:
SCHEDULE_ADMIN- Full CRUD access to schedulesSCHEDULE_VIEWER- Read-only access to schedules
Rate Limiting
Per Endpoint Limits:
CreateSchedule: 100 req/minGetSchedule: 500 req/minUpdateSchedule: 100 req/minDeleteSchedule: 50 req/minTriggerSchedule: 200 req/min
Implementation: Token bucket algorithm with distributed rate limiting using Redis.
Idempotency Design
Idempotency Key Strategy:
X-Idempotency-Keyheader for mutation endpoints- Store in Redis with TTL of 5 minutes
- For duplicate requests, return same response as first request
// Example idempotency check in gRPC service
public CreateScheduleResponse createSchedule(CreateScheduleRequest request) {
String idempotencyKey = context.getHeaders().get("X-Idempotency-Key");
if (idempotencyKey != null) {
String cachedResponse = idempotencyCache.get(idempotencyKey);
if (cachedResponse != null) {
return parseCachedResponse(cachedResponse);
}
}
// Process request...
String response = serializeResponse(response);
idempotencyCache.put(idempotencyKey, response, Duration.ofMinutes(5));
return response;
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
CRON_JOB {
string id PK
string name
string cron_expression
string description
string status
string owner_id
string schedule_type
timestamp created_at
timestamp updated_at
timestamp deleted_at
boolean is_active
string timezone
string payload
}
CRON_EXECUTION {
string id PK
string job_id FK
string status
timestamp scheduled_at
timestamp executed_at
timestamp completed_at
string result
string error_message
integer retry_count
timestamp created_at
timestamp updated_at
}
CRON_SCHEDULE {
string id PK
string job_id FK
timestamp next_run_at
timestamp last_run_at
integer run_count
timestamp created_at
timestamp updated_at
}
CRON_JOB ||--o{ CRON_EXECUTION : "has"
CRON_JOB ||--o{ CRON_SCHEDULE : "has"
Table Schema Definitions
-- Cron Job table
CREATE TABLE cron_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
cron_expression VARCHAR(100) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'ACTIVE',
owner_id UUID NOT NULL,
schedule_type VARCHAR(50) NOT NULL DEFAULT 'RECURRING',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
payload TEXT,
-- Indexes
INDEX idx_cron_jobs_owner_id (owner_id),
INDEX idx_cron_jobs_status (status),
INDEX idx_cron_jobs_is_active (is_active),
INDEX idx_cron_jobs_deleted_at (deleted_at)
);
-- Cron Execution table
CREATE TABLE cron_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL,
executed_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
result TEXT,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Indexes
INDEX idx_cron_executions_job_id (job_id),
INDEX idx_cron_executions_status (status),
INDEX idx_cron_executions_scheduled_at (scheduled_at),
INDEX idx_cron_executions_created_at (created_at)
);
-- Cron Schedule table
CREATE TABLE cron_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES cron_jobs(id) ON DELETE CASCADE,
next_run_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_run_at TIMESTAMP WITH TIME ZONE,
run_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Indexes
INDEX idx_cron_schedules_job_id (job_id),
INDEX idx_cron_schedules_next_run_at (next_run_at),
INDEX idx_cron_schedules_created_at (created_at)
);
Primary Key Strategy
UUID Strategy: Using UUIDs as primary keys for all tables to support:
- Distributed system compatibility
- No single point of failure in sharding
- Better security through obfuscation of sequential IDs
- Easier cross-system integration
Index Design
-
cron_jobs:
idx_cron_jobs_owner_id: For owner-based filtering and access controlidx_cron_jobs_status: For status-based queries (active/inactive jobs)idx_cron_jobs_is_active: For filtering active jobs efficientlyidx_cron_jobs_deleted_at: For soft-delete queries and archive operations
-
cron_executions:
idx_cron_executions_job_id: For job-specific execution historyidx_cron_executions_status: For status-based filtering (failed, succeeded, pending)idx_cron_executions_scheduled_at: For finding jobs due for executionidx_cron_executions_created_at: For time-based query operations
-
cron_schedules:
idx_cron_schedules_job_id: For job-specific schedule lookupsidx_cron_schedules_next_run_at: For efficient scheduler queriesidx_cron_schedules_created_at: For historical tracking
Data Partitioning / Sharding Strategy
Sharding by Owner ID:
- Partition cron jobs by
owner_idhash to distribute load across shards - Each shard maintains its own scheduler and execution engine
- Cross-shard job dependencies handled via inter-service communication
Soft Delete vs. Hard Delete
Soft Delete Strategy:
deleted_atfield added to all tables- Jobs marked as deleted but retained for audit and recovery purposes
- Execution history preserved for debugging and compliance
Audit Trail Design
All tables include:
created_at: Timestamp of creationupdated_at: Last modification timestampowner_id: User or service that owns the job
Data Archival Strategy
30-day retention policy:
- Active jobs and executions retained for 30 days
- Historical data archived to cold storage (e.g., S3)
- Metadata retained for compliance and analytics
- Archive process runs nightly to move old data
// Example of audit trail in Java
public class AuditTrail {
private String userId;
private String operation;
private String entityId;
private String timestamp;
private String details;
// Getters and setters
}
4. Class Design & Service Layer
classDiagram
class CronSchedulerService {
+scheduleJob(JobRequest)
+cancelJob(String jobId)
+pauseJob(String jobId)
+resumeJob(String jobId)
+getJobStatus(String jobId)
+listJobs()
+triggerJobNow(String jobId)
}
class JobRepository {
+save(Job job)
+findById(String id)
+findAll()
+update(Job job)
+delete(String id)
+findByStatus(JobStatus status)
}
class JobScheduler {
+scheduleJob(String jobId, Job job)
+cancelScheduledJob(String jobId)
+executeJob(String jobId)
}
class Job {
-id: String
-name: String
-cronExpression: String
-status: JobStatus
-nextRunTime: Instant
-lastRunTime: Instant
-scheduleType: ScheduleType
-payload: String
+validate()
+calculateNextRunTime()
}
class JobStatus {
PENDING
RUNNING
COMPLETED
FAILED
PAUSED
CANCELLED
}
class ScheduleType {
CRON
INTERVAL
DATE_TIME
}
class JobExecutionService {
+executeJob(String jobId)
+handleJobFailure(String jobId, Exception error)
+handleJobSuccess(String jobId, JobResult result)
}
class JobResult {
-jobId: String
-status: JobExecutionStatus
-output: String
-error: String
-executionTime: Instant
}
class JobExecutionStatus {
SUCCESS
FAILURE
TIMEOUT
}
class EventPublisher {
+publishJobEvent(JobEvent event)
}
class JobEvent {
-jobId: String
-eventType: JobEventType
-timestamp: Instant
-payload: String
}
class JobEventType {
JOB_SCHEDULED
JOB_STARTED
JOB_COMPLETED
JOB_FAILED
JOB_CANCELLED
JOB_PAUSED
JOB_RESUMED
}
CronSchedulerService --> JobRepository : uses
CronSchedulerService --> JobScheduler : orchestrates
CronSchedulerService --> JobExecutionService : delegates
CronSchedulerService --> EventPublisher : publishes
JobScheduler --> JobRepository : persists
JobScheduler --> JobExecutionService : triggers
JobScheduler --> EventPublisher : publishes
JobExecutionService --> EventPublisher : publishes
Job --> JobStatus : has
Job --> ScheduleType : has
JobResult --> JobExecutionStatus : has
JobEvent --> JobEventType : has
class CronJobManager {
+start()
+stop()
+addJob(Job job)
+removeJob(String jobId)
+processScheduledJobs()
}
CronJobManager --> JobScheduler : delegates
CronJobManager --> JobRepository : persists
stateDiagram-v2
[*] --> PENDING
PENDING --> RUNNING : Scheduled
PENDING --> CANCELLED : Cancelled
RUNNING --> COMPLETED : Success
RUNNING --> FAILED : Failure
RUNNING --> PENDING : Retry
COMPLETED --> [*]
FAILED --> [*]
CANCELLED --> [*]
PENDING --> PAUSED : Paused
PAUSED --> PENDING : Resumed
RUNNING --> PAUSED : Paused
PAUSED --> RUNNING : Resumed
Service Class Structure and Responsibilities
The Cron Scheduler system is composed of several key service classes:
- CronSchedulerService - Main orchestration layer that handles all external API interactions and business logic flow
- JobScheduler - Core scheduling engine that manages job execution timing and lifecycle
- JobRepository - Data persistence layer for job metadata and state
- JobExecutionService - Handles actual job execution and result processing
- CronJobManager - Background task manager that polls for scheduled jobs
Interface and Abstract Class Design
public interface JobRepository {
Job save(Job job);
Optional<Job> findById(String id);
List<Job> findAll();
Job update(Job job);
void delete(String id);
List<Job> findByStatus(JobStatus status);
}
public interface JobScheduler {
void scheduleJob(String jobId, Job job);
void cancelScheduledJob(String jobId);
void executeJob(String jobId);
}
public interface EventPublisher {
void publishJobEvent(JobEvent event);
}
Business Logic Encapsulation Patterns
Strategy Pattern - For handling different schedule types:
public interface ScheduleStrategy {
Instant calculateNextRunTime(Job job);
}
@Component
public class CronScheduleStrategy implements ScheduleStrategy {
private final CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronParser.CRON4J));
@Override
public Instant calculateNextRunTime(Job job) {
try {
CronSchedule schedule = parser.parse(job.getCronExpression()).oneTime();
return schedule.nextExecution(Instant.now().atZone(ZoneId.systemDefault()).toLocalDateTime())
.atZone(ZoneId.systemDefault())
.toInstant();
} catch (ParseException e) {
throw new IllegalArgumentException("Invalid cron expression: " + job.getCronExpression(), e);
}
}
}
@Component
public class IntervalScheduleStrategy implements ScheduleStrategy {
@Override
public Instant calculateNextRunTime(Job job) {
return Instant.now().plus(job.getIntervalDuration());
}
}
public class ScheduleStrategyFactory {
private final Map<ScheduleType, ScheduleStrategy> strategies = new HashMap<>();
public ScheduleStrategyFactory() {
strategies.put(ScheduleType.CRON, new CronScheduleStrategy());
strategies.put(ScheduleType.INTERVAL, new IntervalScheduleStrategy());
}
public ScheduleStrategy getStrategy(ScheduleType type) {
return strategies.getOrDefault(type, new CronScheduleStrategy());
}
}
Critical-Path Method Implementation
@Transactional
public JobResult triggerJobNow(String jobId) {
// 1. Validate job exists and is in correct state
Job job = jobRepository.findById(jobId)
.orElseThrow(() -> new JobNotFoundException("Job not found: " + jobId));
if (job.getStatus() == JobStatus.CANCELLED || job.getStatus() == JobStatus.FAILED) {
throw new IllegalStateException("Cannot trigger cancelled or failed job: " + jobId);
}
// 2. Update job status to RUNNING
job.setStatus(JobStatus.RUNNING);
job.setLastRunTime(Instant.now());
jobRepository.update(job);
// 3. Publish job started event
eventPublisher.publishJobEvent(new JobEvent(
jobId,
JobEventType.JOB_STARTED,
Instant.now(),
job.getPayload()
));
// 4. Execute the job with proper error handling and transaction boundaries
try {
JobResult result = executeJobInternal(job);
// 5. Update job status based on result
job.setStatus(result.getStatus() == JobExecutionStatus.SUCCESS ?
JobStatus.COMPLETED : JobStatus.FAILED);
job.setLastRunTime(Instant.now());
// 6. Publish completion event
eventPublisher.publishJobEvent(new JobEvent(
jobId,
result.getStatus() == JobExecutionStatus.SUCCESS ?
JobEventType.JOB_COMPLETED : JobEventType.JOB_FAILED,
Instant.now(),
result.getOutput()
));
jobRepository.update(job);
return result;
} catch (Exception e) {
// 7. Handle job failure
job.setStatus(JobStatus.FAILED);
job.setLastRunTime(Instant.now());
jobRepository.update(job);
eventPublisher.publishJobEvent(new JobEvent(
jobId,
JobEventType.JOB_FAILED,
Instant.now(),
"Job execution failed: " + e.getMessage()
));
throw new JobExecutionException("Failed to execute job: " + jobId, e);
}
}
private JobResult executeJobInternal(Job job) {
// This is where actual job logic would be implemented
// Could involve calling external services, processing data, etc.
try {
String output = "Executed job: " + job.getName() + " at " + Instant.now();
return new JobResult(
job.getId(),
JobExecutionStatus.SUCCESS,
output,
null,
Instant.now()
);
} catch (Exception e) {
return new JobResult(
job.getId(),
JobExecutionStatus.FAILURE,
null,
e.getMessage(),
Instant.now()
);
}
}
Transaction Boundary Design
Each job execution is wrapped in a single database transaction that:
- Begins when the job is scheduled for immediate execution
- Commits or rolls back based on success/failure of the entire job lifecycle
- Ensures atomicity of job state updates and event publishing
Service Interface Contracts
public interface CronSchedulerService {
JobResult scheduleJob(JobRequest request);
void cancelJob(String jobId);
void pauseJob(String jobId);
void resumeJob(String jobId);
JobStatus getJobStatus(String jobId);
List<Job> listJobs();
JobResult triggerJobNow(String jobId);
}
Saga / Orchestration Pattern
For complex jobs that involve multiple external systems, the system uses a saga pattern:
public class JobSaga {
private final List<JobStep> steps;
private final String sagaId;
public void execute() {
for (JobStep step : steps) {
try {
step.execute();
// Update saga state
updateSagaState(SagaState.EXECUTING);
} catch (Exception e) {
// Compensate previous steps
compensateSteps();
updateSagaState(SagaState.FAILED);
throw new SagaExecutionException("Saga failed", e);
}
}
updateSagaState(SagaState.COMPLETED);
}
}
Domain Object / Value Object Design
public class Job {
private String id;
private String name;
private String cronExpression;
private JobStatus status;
private Instant nextRunTime;
private Instant lastRunTime;
private ScheduleType scheduleType;
private String payload;
private Duration intervalDuration;
private Instant createdAt;
private Instant updatedAt;
// Getters and setters with validation
public void validate() {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Job name cannot be null or empty");
}
if (scheduleType == ScheduleType.CRON && cronExpression == null) {
throw new IllegalArgumentException("Cron expression required for CRON schedule type");
}
if (scheduleType == ScheduleType.INTERVAL && intervalDuration == null) {
throw new IllegalArgumentException("Interval duration required for INTERVAL schedule type");
}
}
public Instant calculateNextRunTime() {
ScheduleStrategy strategy = scheduleStrategyFactory.getStrategy(scheduleType);
return strategy.calculateNextRunTime(this);
}
}
public enum JobStatus {
PENDING, RUNNING, COMPLETED, FAILED, PAUSED, CANCELLED
}
public enum ScheduleType {
CRON, INTERVAL, DATE_TIME
}
5. Concurrency & Thread Safety
Concurrency Model
The cron scheduler will use a single-threaded event loop model with async/await pattern for handling concurrent job execution. This approach is chosen to:
- Simplify thread safety - Avoid complex locking mechanisms and race conditions
- Reduce memory overhead - Single-threaded model uses minimal memory for thread context switching
- Enable efficient I/O handling - Non-blocking I/O operations can be handled efficiently in async context
- Support high-concurrency workloads - Async model can handle thousands of concurrent job executions without thread contention
Race Condition Prevention Strategy
Race conditions in the scheduler are mitigated through:
- Immutable job state - Once a job is created, its configuration remains constant
- Atomic job status updates - Using atomic operations for status transitions (PENDING → RUNNING → COMPLETED/FAILED)
- Thread-safe job queue - Using concurrent collections (ConcurrentHashMap, BlockingQueue) for managing jobs
- Synchronized access to shared resources - Critical sections protected with synchronized blocks or read-write locks
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for job execution due to:
- High concurrency scenarios - With potentially thousands of concurrent jobs, pessimistic locking would cause significant contention
- Low write frequency - Job status updates are infrequent compared to read operations
- Better performance characteristics - Optimistic locking allows for better throughput in high-concurrency scenarios
- Simplified error handling - Conflicts are detected at commit time and handled gracefully
Implementation approach:
// Pseudo-code for optimistic locking in job execution
public class JobExecutionService {
private final Map<String, Job> activeJobs = new ConcurrentHashMap<>();
public boolean executeJob(String jobId) {
Job job = activeJobs.get(jobId);
if (job == null) return false;
// Read current version
long currentVersion = job.getVersion();
try {
// Perform job execution logic
executeJobLogic(job);
// Update with version check (optimistic locking)
if (job.updateVersion(currentVersion)) {
job.setStatus(JobStatus.COMPLETED);
return true;
} else {
// Conflict - another process updated the job
throw new JobConflictException("Job was modified by another process");
}
} catch (Exception e) {
job.setStatus(JobStatus.FAILED);
throw e;
}
}
}
Deadlock Prevention Strategy
Deadlock prevention mechanisms include:
- Fixed ordering of resource acquisition - All job operations follow a consistent order for accessing shared resources
- Timeout-based lock acquisition - Implement timeouts to prevent indefinite blocking
- Hierarchical resource management - Jobs are organized in a hierarchy where parent-child relationships prevent circular waits
- Monitoring and alerting - Runtime detection of potential deadlocks with automatic recovery mechanisms
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented through:
- Job execution tracking - Each job execution is tracked with a unique identifier
- Idempotency key strategy - Using job ID + execution timestamp as idempotency key
- Duplicate detection mechanism - Check for existing job execution before starting new one
- State-based idempotency - If a job is already in RUNNING or COMPLETED state, subsequent requests are ignored
public class IdempotentJobExecutor {
private final Set<String> activeExecutions = new ConcurrentHashMap<>();
public JobResult executeJobWithIdempotency(String jobId, String executionKey) {
// Check if this execution is already in progress
if (activeExecutions.contains(executionKey)) {
// Return cached result or wait for completion
return getExistingResult(jobId);
}
// Acquire exclusive lock for this job execution
try {
if (activeExecutions.add(executionKey)) {
return executeJob(jobId);
} else {
// Another thread acquired the lock first
return getExistingResult(jobId);
}
} finally {
activeExecutions.remove(executionKey);
}
}
}
Thread Safety in Job Scheduling Loop
The main scheduling loop is designed to be thread-safe:
- Immutable configuration objects - Scheduler configuration is read-only once initialized
- Thread-safe time comparison - Using atomic operations for time-based comparisons
- Synchronized job queue management - All queue modifications are thread-safe operations
- Isolated execution contexts - Each job runs in its own isolated execution context
Concurrency Control Patterns
- Read-Write Locks - For read-heavy operations on job metadata
- StampedLock - For fine-grained control over concurrent access to job state
- Semaphore-based limiting - To limit concurrent job executions to prevent resource exhaustion
public class ConcurrentJobScheduler {
private final ReadWriteLock jobMetadataLock = new ReentrantReadWriteLock();
private final Semaphore executionSemaphore = new Semaphore(100); // Max 100 concurrent jobs
public void scheduleJob(Job job) {
// Write lock for metadata update
jobMetadataLock.writeLock().lock();
try {
jobStore.put(job.getId(), job);
} finally {
jobMetadataLock.writeLock().unlock();
}
// Schedule for execution
scheduleForExecution(job);
}
private void executeJob(Job job) {
try {
executionSemaphore.acquire();
// Execute job logic
executeJobLogic(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Job execution interrupted", e);
} finally {
executionSemaphore.release();
}
}
}
Performance Considerations
- Minimal thread contention - Using async model with minimal blocking
- Efficient resource utilization - Thread pool size tuned for optimal throughput
- Memory-efficient concurrent operations - Using lock-free data structures where possible
- Scalable architecture - Design allows for horizontal scaling across multiple scheduler instances
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant U as User
participant API as CronSchedulerAPI
participant Service as CronSchedulerService
participant DB as Database
participant Scheduler as JobScheduler
participant Executor as JobExecutor
U->>API: POST /jobs
API->>Service: createJob(request)
Service->>DB: save(job)
DB-->>Service: jobId
Service->>Scheduler: scheduleJob(jobId, cronExpression)
Scheduler-->>Service: success
Service-->>API: 201 Created (jobId)
API-->>U: job created successfully
Note over U,Executor: Scheduled job execution
Scheduler->>Executor: executeJob(jobId)
Executor->>DB: fetch(jobId)
DB-->>Executor: job details
Executor->>Service: executeJob(jobId, payload)
Service-->>Executor: job result
Executor->>DB: update(jobId, status, result)
DB-->>Executor: success
sequenceDiagram
participant U as User
participant API as CronSchedulerAPI
participant Service as CronSchedulerService
participant DB as Database
participant Scheduler as JobScheduler
participant Executor as JobExecutor
U->>API: GET /jobs/{id}/status
API->>Service: getJobStatus(jobId)
Service->>DB: fetch(jobId)
DB-->>Service: job status
Service-->>API: 200 OK (status)
API-->>U: job status
U->>API: PUT /jobs/{id}/pause
API->>Service: pauseJob(jobId)
Service->>Scheduler: pauseJob(jobId)
Scheduler-->>Service: success
Service->>DB: update(jobId, status)
DB-->>Service: success
Service-->>API: 200 OK
API-->>U: job paused
U->>API: DELETE /jobs/{id}
API->>Service: deleteJob(jobId)
Service->>Scheduler: unscheduleJob(jobId)
Scheduler-->>Service: success
Service->>DB: delete(jobId)
DB-->>Service: success
Service-->>API: 200 OK
API-->>U: job deleted
sequenceDiagram
participant Scheduler as JobScheduler
participant DB as Database
participant Executor as JobExecutor
participant EventPublisher as EventPublisher
loop Every minute
Scheduler->>DB: queryScheduledJobs()
DB-->>Scheduler: list of jobs
alt job is ready to execute
Scheduler->>Executor: executeJob(jobId)
Executor->>DB: fetch(jobId)
DB-->>Executor: job details
Executor->>Service: executeJob(jobId, payload)
Service-->>Executor: job result
Executor->>DB: update(jobId, status, result, lastRunAt)
DB-->>Executor: success
Executor->>EventPublisher: publishJobCompleted(jobId, result)
EventPublisher-->>Executor: success
end
end
sequenceDiagram
participant Scheduler as JobScheduler
participant DB as Database
participant Executor as JobExecutor
participant EventPublisher as EventPublisher
loop Every minute
Scheduler->>DB: queryScheduledJobs()
DB-->>Scheduler: list of jobs
alt job is ready to execute with error handling
Scheduler->>Executor: executeJob(jobId)
Executor->>DB: fetch(jobId)
DB-->>Executor: job details
Executor->>Service: executeJob(jobId, payload)
Service-->>Executor: job result
Executor->>DB: update(jobId, status, result, lastRunAt)
DB-->>Executor: success
Executor->>EventPublisher: publishJobCompleted(jobId, result)
EventPublisher-->>Executor: success
else job execution failed
Scheduler->>Executor: executeJob(jobId)
Executor->>DB: fetch(jobId)
DB-->>Executor: job details
Executor->>Service: executeJob(jobId, payload)
Service-->>Executor: job error
Executor->>DB: update(jobId, status, error, lastRunAt)
DB-->>Executor: success
Executor->>EventPublisher: publishJobFailed(jobId, error)
EventPublisher-->>Executor: success
end
end
7. Caching Design
Cache Strategy Selection
For a cron scheduler, we'll use cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for our use case where:
- Scheduled jobs are frequently accessed during execution windows
- Job definitions and metadata change infrequently
- We need to maintain strong consistency with database state for job scheduling decisions
Cache Key Design
Cache keys will follow a structured namespace pattern:
cron:scheduler:{jobId}
cron:scheduler:active_jobs
cron:scheduler:next_run:{jobId}
cron:scheduler:job_status:{jobId}
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Job definitions | 1 hour | Frequent reads, infrequent updates |
| Active jobs list | 5 minutes | High access frequency, needs timely refresh |
| Next run timestamps | 10 minutes | Critical for scheduling accuracy |
| Job status | 30 minutes | Recent execution status, not real-time |
Cache Eviction Policy
We'll implement LRU (Least Recently Used) eviction policy with:
- Size limit: 10,000 entries per cache instance
- Memory-based eviction with soft TTLs (5% grace period)
- Background cleanup thread to handle expired entries
Cache Stampede Prevention
To prevent cache stampedes:
- Probabilistic early expiry: 10% of TTL for soft expiration
- Distributed locks: For critical job state updates using Redis distributed locks
- Cache warming: Pre-warm frequently accessed jobs during startup
Distributed Cache Architecture
We'll use Redis Cluster with:
- 3 master nodes with 3 replicas each (6 total instances)
- Automatic sharding based on job ID hash
- Redis Sentinel for high availability and failover
- Memory eviction policy set to
allkeys-lru
Cache Invalidation Strategy
Event-driven invalidation with the following rules:
- Job update: Invalidate
cron:scheduler:{jobId}andcron:scheduler:active_jobs - Job deletion: Invalidate all related keys with job ID prefix
- Status change: Invalidate
cron:scheduler:job_status:{jobId}andcron:scheduler:next_run:{jobId} - Active job list: Periodic refresh every 5 minutes or on job state changes
public class CronSchedulerCache {
private final RedisTemplate<String, Object> redisTemplate;
private final CacheManager cacheManager;
public void invalidateJobCache(String jobId) {
String jobKey = "cron:scheduler:" + jobId;
String statusKey = "cron:scheduler:job_status:" + jobId;
String nextRunKey = "cron:scheduler:next_run:" + jobId;
// Use pipeline for atomic invalidation
redisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
connection.del(jobKey.getBytes());
connection.del(statusKey.getBytes());
connection.del(nextRunKey.getBytes());
return null;
}
);
// Invalidate active jobs list
redisTemplate.delete("cron:scheduler:active_jobs");
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract data access logic and provide a clean separation between domain logic and database operations. Each entity has its own repository interface and implementation.
public interface ScheduleRepository {
Schedule save(Schedule schedule);
Optional<Schedule> findById(UUID id);
List<Schedule> findAll();
List<Schedule> findByStatus(ScheduleStatus status);
Page<Schedule> findByUserId(UUID userId, Pageable pageable);
void deleteById(UUID id);
boolean existsById(UUID id);
}
public class ScheduleRepositoryImpl implements ScheduleRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public ScheduleRepositoryImpl(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
@Override
public Schedule save(Schedule schedule) {
if (schedule.getId() == null) {
return createSchedule(schedule);
} else {
return updateSchedule(schedule);
}
}
private Schedule createSchedule(Schedule schedule) {
String sql = """
INSERT INTO schedules (id, user_id, cron_expression, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""";
UUID id = UUID.randomUUID();
schedule.setId(id);
schedule.setCreatedAt(Instant.now());
schedule.setUpdatedAt(Instant.now());
jdbcTemplate.update(sql,
id,
schedule.getUserId(),
schedule.getCronExpression(),
schedule.getStatus().name(),
schedule.getCreatedAt(),
schedule.getUpdatedAt());
return schedule;
}
private Schedule updateSchedule(Schedule schedule) {
String sql = """
UPDATE schedules
SET cron_expression = ?, status = ?, updated_at = ?
WHERE id = ?
""";
schedule.setUpdatedAt(Instant.now());
jdbcTemplate.update(sql,
schedule.getCronExpression(),
schedule.getStatus().name(),
schedule.getUpdatedAt(),
schedule.getId());
return schedule;
}
@Override
public Optional<Schedule> findById(UUID id) {
String sql = "SELECT * FROM schedules WHERE id = ?";
try {
return Optional.of(jdbcTemplate.queryForObject(sql, new ScheduleRowMapper(), id));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
@Override
public List<Schedule> findAll() {
String sql = "SELECT * FROM schedules ORDER BY created_at DESC";
return jdbcTemplate.query(sql, new ScheduleRowMapper());
}
@Override
public List<Schedule> findByStatus(ScheduleStatus status) {
String sql = "SELECT * FROM schedules WHERE status = ? ORDER BY created_at DESC";
return jdbcTemplate.query(sql, new ScheduleRowMapper(), status.name());
}
@Override
public Page<Schedule> findByUserId(UUID userId, Pageable pageable) {
String sql = """
SELECT * FROM schedules
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""";
List<Schedule> schedules = jdbcTemplate.query(sql,
new ScheduleRowMapper(),
userId,
pageable.getPageSize(),
pageable.getOffset());
long total = countByUserId(userId);
return new PageImpl<>(schedules, pageable, total);
}
private long countByUserId(UUID userId) {
String sql = "SELECT COUNT(*) FROM schedules WHERE user_id = ?";
return jdbcTemplate.queryForObject(sql, Long.class, userId);
}
@Override
public void deleteById(UUID id) {
String sql = "DELETE FROM schedules WHERE id = ?";
jdbcTemplate.update(sql, id);
}
@Override
public boolean existsById(UUID id) {
String sql = "SELECT EXISTS(SELECT 1 FROM schedules WHERE id = ?)";
return jdbcTemplate.queryForObject(sql, Boolean.class, id);
}
}
ORM vs. Raw Query Decisions
For this system, raw SQL queries are preferred over ORM for several reasons:
- Performance: Raw queries provide better performance for complex operations and reduce object mapping overhead
- Control: Direct control over query execution and optimization
- Complexity: Cron scheduling queries are relatively simple, making ORM overhead unnecessary
However, for simpler operations like basic CRUD, a lightweight ORM (e.g., MyBatis) could be considered to reduce boilerplate code.
Read vs. Write Separation
The system implements CQRS (Command Query Responsibility Segregation) pattern for read and write operations:
public interface ScheduleReadRepository {
List<Schedule> findActiveSchedules();
Schedule findNextScheduledRun(UUID scheduleId);
}
public class ScheduleReadRepositoryImpl implements ScheduleReadRepository {
private final JdbcTemplate jdbcTemplate;
public ScheduleReadRepositoryImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public List<Schedule> findActiveSchedules() {
String sql = """
SELECT * FROM schedules
WHERE status = 'ACTIVE'
AND next_run_at <= ?
ORDER BY next_run_at ASC
""";
return jdbcTemplate.query(sql, new ScheduleRowMapper(), Instant.now());
}
@Override
public Schedule findNextScheduledRun(UUID scheduleId) {
String sql = """
SELECT * FROM schedules
WHERE id = ?
AND status = 'ACTIVE'
""";
try {
return jdbcTemplate.queryForObject(sql, new ScheduleRowMapper(), scheduleId);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
}
Connection Pool Configuration
Connection pool is configured with:
- Max pool size: 20
- Min idle connections: 5
- Connection timeout: 30 seconds
- Validation query:
SELECT 1
@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/cron_scheduler");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
config.setValidationTimeout(5000);
config.setLeakDetectionThreshold(60000);
config.setConnectionTestQuery("SELECT 1");
return new HikariDataSource(config);
}
}
N+1 Query Prevention Strategy
To prevent N+1 queries, eager loading is used with batch operations:
public class ScheduleBatchService {
private final ScheduleRepository scheduleRepository;
public List<Schedule> findSchedulesWithRuns(List<UUID> scheduleIds) {
// Single query to fetch all schedules
String sql = "SELECT * FROM schedules WHERE id IN (" +
scheduleIds.stream().map(id -> "?").collect(Collectors.joining(",")) + ")";
// Use batch processing to avoid N+1
List<Schedule> schedules = jdbcTemplate.query(sql,
scheduleIds.toArray(), new ScheduleRowMapper());
// Preload related data in batch
loadScheduleRuns(schedules);
return schedules;
}
private void loadScheduleRuns(List<Schedule> schedules) {
if (schedules.isEmpty()) return;
List<UUID> scheduleIds = schedules.stream()
.map(Schedule::getId)
.collect(Collectors.toList());
String runSql = "SELECT * FROM schedule_runs WHERE schedule_id IN (" +
scheduleIds.stream().map(id -> "?").collect(Collectors.joining(",")) + ")";
// Process runs in batch
List<ScheduleRun> runs = jdbcTemplate.query(runSql,
scheduleIds.toArray(), new ScheduleRunRowMapper());
// Group runs by schedule ID
Map<UUID, List<ScheduleRun>> runMap = runs.stream()
.collect(Collectors.groupingBy(ScheduleRun::getScheduleId));
schedules.forEach(schedule -> {
schedule.setRuns(runMap.getOrDefault(schedule.getId(), Collections.emptyList()));
});
}
}
Batch Operation Design
Batch operations are implemented for bulk scheduling:
public class ScheduleBatchRepository {
private final JdbcTemplate jdbcTemplate;
public void batchCreateSchedules(List<Schedule> schedules) {
String sql = """
INSERT INTO schedules (id, user_id, cron_expression, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""";
// Prepare batch
BatchPreparedStatementSetter setter = new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Schedule schedule = schedules.get(i);
ps.setObject(1, schedule.getId());
ps.setObject(2, schedule.getUserId());
ps.setString(3, schedule.getCronExpression());
ps.setString(4, schedule.getStatus().name());
ps.setTimestamp(5, Timestamp.from(schedule.getCreatedAt()));
ps.setTimestamp(6, Timestamp.from(schedule.getUpdatedAt()));
}
@Override
public int getBatchSize() {
return schedules.size();
}
};
jdbcTemplate.batchUpdate(sql, setter);
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The cron scheduler system is designed to be primarily synchronous for job creation and management, but leverages asynchronous processing for job execution and monitoring. The key boundary is:
- Synchronous: Job creation, update, deletion, and status querying are handled synchronously via the REST API to ensure immediate consistency and user feedback.
- Asynchronous: Actual job execution, retry logic, and monitoring are handled asynchronously through a message queue to decouple the scheduler from job execution and improve system resilience.
This design allows for better scalability of job execution while maintaining a clean, synchronous API for management operations. The asynchronous processing ensures that job failures don't block the scheduler's operation and enables better handling of burst job loads.
Message schema design
{
"version": "1.0",
"event_type": "job.execution",
"timestamp": "2023-10-27T14:30:00Z",
"job_id": "uuid-12345",
"schedule_id": "uuid-67890",
"payload": {
"command": "run-report-generation",
"parameters": {
"report_type": "daily",
"output_path": "/reports/daily-2023-10-27.json"
}
},
"retry_count": 0,
"max_retries": 3
}
Dead letter queue strategy and poison message handling
For jobs that fail consistently, we implement a dead letter queue (DLQ) strategy:
- Retry Logic: Jobs are retried up to 3 times with exponential backoff (1s, 2s, 4s)
- DLQ: Jobs that fail after max retries are moved to a DLQ topic with a 30-day retention
- Manual Intervention: Failed jobs in DLQ are flagged for manual review and can be retried or archived
- Monitoring: DLQ queue size and failure rate are monitored with alerts
At-least-once vs. exactly-once delivery
We choose at-least-once delivery for job execution because:
- Simplicity: Easier to implement and maintain
- Reliability: Ensures jobs are not lost due to system failures
- Idempotency: Jobs are designed to be idempotent or have built-in deduplication logic
- Trade-off: The risk of duplicate execution is acceptable for most use cases and can be mitigated through job state tracking
Retry policy for consumers
Retry policy configuration:
- Max attempts: 3 retries
- Backoff formula: Exponential with jitter (1s, 2s, 4s)
- Jitter: ±20% randomization to prevent thundering herd
- Non-retryable errors:
- Invalid command or payload
- Permission denied (user not authorized)
- Resource exhaustion (out of memory, disk space)
Outbox pattern for reliable event publishing
We implement the outbox pattern for job execution events:
- Transaction Boundary: When a job is scheduled, the event is written to an
outbox_eventstable within the same database transaction as job creation - Async Publisher: A background worker polls the outbox table and publishes events to the message broker
- Idempotency: Events are marked as published once successfully delivered to avoid duplicates
- Reliability: If publishing fails, the event remains in outbox for retry
// OutboxEvent.java
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
private UUID id;
@Column(name = "event_type")
private String eventType;
@Column(name = "payload")
private String payload;
@Column(name = "status")
private String status; // PENDING, PROCESSED, FAILED
@Column(name = "created_at")
private Instant createdAt;
// Getters and setters
}
Sequence diagram for async flows
sequenceDiagram
participant Scheduler
participant DB
participant MessageBroker
participant JobExecutor
Scheduler->>DB: Create job with schedule
DB-->>Scheduler: Return job_id
Scheduler->>DB: Insert outbox_event for job_execution
DB-->>Scheduler: Confirm event stored
Scheduler->>MessageBroker: Publish job_execution event
MessageBroker-->>Scheduler: Acknowledge
MessageBroker->>JobExecutor: Deliver job_execution event
JobExecutor-->>MessageBroker: Acknowledge success
MessageBroker->>DB: Update job status to EXECUTING
DB-->>MessageBroker: Confirm update
JobExecutor->>DB: Update job status to COMPLETED
DB-->>JobExecutor: Confirm update
JobExecutor->>MessageBroker: Publish job_completion event
MessageBroker-->>JobExecutor: Acknowledge
Note over JobExecutor,MessageBroker: Retry logic if job fails
The outbox pattern ensures that job execution events are reliably published even if the database transaction commits but the message broker fails temporarily. This provides strong consistency guarantees for job scheduling operations while maintaining the scalability benefits of asynchronous processing.
The design allows for horizontal scaling of job executors and provides a robust foundation for handling job failures, retries, and monitoring. The message broker (e.g., Kafka or RabbitMQ) handles the queuing and distribution of job execution requests to worker nodes, enabling efficient parallel processing of scheduled jobs.
10. Error Handling Design
Error Classification Hierarchy
Errors in the cron scheduler are categorized into three main types:
- Validation Errors - Input data does not meet business rules or format requirements
- Infrastructure Errors - System-level issues like database connectivity, network failures, or resource exhaustion
- Domain Errors - Business logic violations such as duplicate cron jobs, invalid schedule expressions, or permission issues
public class CronSchedulerError extends RuntimeException {
private final ErrorType errorType;
private final String errorCode;
public CronSchedulerError(ErrorType errorType, String errorCode, String message) {
super(message);
this.errorType = errorType;
this.errorCode = errorCode;
}
public enum ErrorType {
VALIDATION, INFRASTRUCTURE, DOMAIN
}
}
public class JobValidationException extends CronSchedulerError {
public JobValidationException(String message) {
super(ErrorType.VALIDATION, "INVALID_JOB", message);
}
}
public class JobExecutionException extends CronSchedulerError {
public JobExecutionException(String message) {
super(ErrorType.DOMAIN, "EXECUTION_FAILED", message);
}
}
public class DatabaseConnectionException extends CronSchedulerError {
public DatabaseConnectionException(String message) {
super(ErrorType.INFRASTRUCTURE, "DB_CONNECTION_ERROR", message);
}
}
Error Propagation Strategy
Errors are handled at the appropriate layer with minimal propagation:
- API Layer: Converts internal exceptions to standardized HTTP responses
- Service Layer: Handles business logic errors and rethrows domain exceptions
- Repository Layer: Wraps database exceptions in application-specific ones
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors, a retry mechanism is implemented:
public class RetryableOperation {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation) {
Exception lastException = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return operation.get();
} catch (DatabaseConnectionException | NetworkException e) {
lastException = e;
if (attempt < MAX_RETRIES) {
long delay = calculateBackoffDelay(attempt);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Retry interrupted", ie);
}
}
}
}
throw new RuntimeException("Operation failed after " + MAX_RETRIES + " retries", lastException);
}
private long calculateBackoffDelay(int attempt) {
double randomFactor = 1.0 + Math.random() * JITTER_FACTOR;
return (long) (BASE_DELAY_MS * Math.pow(2, attempt) * randomFactor);
}
}
Circuit Breaker Design
For external dependencies like job execution services, a circuit breaker pattern prevents cascading failures:
public class CircuitBreaker {
private final int failureThreshold;
private final long timeoutMs;
private final AtomicInteger failureCount = new AtomicInteger(0);
private final AtomicLong lastFailureTime = new AtomicLong(0);
private volatile CircuitState state = CircuitState.CLOSED;
public enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
return executeWhenClosed(operation);
case OPEN:
if (isTimeoutElapsed()) {
state = CircuitState.HALF_OPEN;
return executeWhenHalfOpen(operation);
} else {
throw new CircuitBreakerOpenException("Circuit is open");
}
case HALF_OPEN:
return executeWhenHalfOpen(operation);
}
throw new IllegalStateException("Invalid circuit state");
}
private <T> T executeWhenClosed(Supplier<T> operation) {
try {
T result = operation.get();
failureCount.set(0);
return result;
} catch (Exception e) {
failureCount.incrementAndGet();
lastFailureTime.set(System.currentTimeMillis());
if (failureCount.get() >= failureThreshold) {
state = CircuitState.OPEN;
}
throw e;
}
}
private boolean isTimeoutElapsed() {
return System.currentTimeMillis() - lastFailureTime.get() > timeoutMs;
}
}
Fallback Behavior Design
Fallbacks are implemented for critical operations:
public class JobExecutionService {
private final CircuitBreaker circuitBreaker;
public JobResult executeJobWithFallback(Job job) {
try {
return circuitBreaker.execute(() -> executeJob(job));
} catch (Exception e) {
// Fallback to logging and notification
logger.warn("Job execution failed, using fallback: {}", job.getId(), e);
return new JobResult(job.getId(), "FAILED", "Fallback executed due to failure");
}
}
}
Error Logging Format and Alerting Thresholds
Structured logging with consistent format:
public class ErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
public void logError(String operation, String errorCode, String errorMessage,
Map<String, Object> context) {
Map<String, Object> logContext = new HashMap<>(context);
logContext.put("operation", operation);
logContext.put("errorCode", errorCode);
logContext.put("errorMessage", errorMessage);
logContext.put("timestamp", Instant.now().toString());
logger.error("Cron Scheduler Error: {}", logContext);
}
}
Alerting thresholds:
- Critical: > 10 errors per minute for job execution failures
- Warning: > 5 errors per minute for database connection issues
- Info: > 100 errors per hour for validation failures (should be rare)
Error Response Format
Standardized JSON error response:
{
"error": {
"code": "EXECUTION_FAILED",
"message": "Job execution failed due to timeout",
"timestamp": "2023-10-15T14:30:00Z",
"traceId": "abc123def456"
}
}
11. Security Design
Input Validation and Sanitization Strategy
All cron expressions and schedule parameters undergo strict validation using regex patterns to prevent injection attacks. The system uses a dedicated CronExpressionValidator that validates:
- Cron expression format using regex pattern
^(\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9]) (\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9]) (\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9]) (\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9]) (\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9]) (\*|[0-9]|[0-9]-[0-9]|[0-9]/[0-9]|[0-9]-[0-9]/[0-9])$ - Job name and description fields are sanitized using OWASP Java HTML Sanitizer to remove potentially dangerous characters
- All user inputs are escaped before logging or displaying
SQL Injection Prevention
All database queries use parameterized prepared statements exclusively. The system uses MyBatis with named parameters and never concatenates user input into SQL strings. For example:
@Select("SELECT * FROM cron_jobs WHERE user_id = #{userId} AND status = #{status}")
List<CronJob> findByUserIdAndStatus(@Param("userId") String userId, @Param("status") String status);
Authentication Middleware Design
The system implements JWT-based authentication with the following flow:
- Client authenticates via OAuth2 or API key endpoint
- Server generates JWT with user ID, role, and expiration (1 hour)
- All protected endpoints require
Authorization: Bearer <token>header - Middleware validates token signature, expiration, and user permissions
Authorization Enforcement
Role-Based Access Control (RBAC) is implemented with the following permission model:
cron:read: Read access to cron jobs (users can view their own jobs)cron:write: Create/update/delete access to cron jobs (users can modify their own jobs)cron:admin: Full access to all cron jobs in the system
Authorization checks occur at service layer using @PreAuthorize annotations:
@PreAuthorize("hasPermission(#jobId, 'cron:write')")
public void updateJob(String jobId, CronJobUpdateRequest request) { ... }
Sensitive Data Encryption at Rest and in Transit
- At rest: All sensitive fields (job payloads, secrets) are encrypted using AES-256-GCM with keys managed by AWS KMS
- In transit: All API communications use TLS 1.3 with HSTS enabled and certificate pinning for client-side validation
PII Handling and Masking Strategy
- User identifiers are masked in logs using a hash function with salt (e.g.,
hash(userId + salt)) - Job payloads containing PII are encrypted before storage and decrypted on-demand
- Audit logs only capture necessary metadata without exposing sensitive content
Secrets Management
Secrets are managed using AWS Secrets Manager with the following design:
- Job-specific secrets (API keys, passwords) are stored as encrypted JSON objects
- Secrets are rotated every 90 days with automatic failover
- Access is controlled via IAM policies that restrict access to specific job IDs
Audit Logging Design
The system logs all security-relevant events with the following schema:
{
"timestamp": "2023-10-15T14:30:00Z",
"event_type": "cron_job_created",
"user_id": "hashed_user_id",
"job_id": "job-12345",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"action": "create",
"details": {
"cron_expression": "* * * * *",
"payload_size_bytes": 1234
}
}
All audit logs are stored in a separate encrypted database table with retention policy of 3 years and accessible only to security administrators.
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85%+ code coverage with focus on business logic, edge cases, and error handling paths
- Repository Layer: 90%+ coverage with emphasis on query correctness and data access patterns
- Domain Objects: 100% coverage for value objects, DTOs, and validation logic
- Scheduler Core: 95%+ coverage for scheduling algorithms, state transitions, and concurrent execution logic
- Event Handlers: 80%+ coverage for async processing and error recovery scenarios
Integration Testing Strategy
- Database Integration: Test with embedded H2/PostgreSQL instances to validate schema, queries, and transaction behavior
- External Service Mocks: Use WireMock or Mockito to simulate external dependencies (e.g., notification service, logging system)
- Message Broker Integration: Utilize Testcontainers to validate event publishing and consumption with Kafka/RabbitMQ
- Caching Layer: Validate cache interaction using embedded Redis or mock implementations
Contract Testing
- API Contract Tests: Implement Pact-based contract testing between scheduler service and consumer services to ensure API compatibility
- Event Schema Contracts: Use Avro schema registry with contract testing tools to validate event payload structure and evolution
- External API Contracts: Test integration points with third-party services (e.g., Slack, email providers) using mock servers
Performance Testing Tool and Key Scenarios
- Load Testing: Use k6 or Gatling to simulate concurrent cron job creation, execution, and monitoring
- Stress Testing: Evaluate system behavior under high load (10k+ concurrent jobs) with gradual ramp-up
- Scalability Testing: Validate horizontal scaling capability across multiple scheduler instances
- Key Scenarios:
- Concurrent job creation and execution (1000 simultaneous requests)
- High-frequency job scheduling (every minute)
- Job state transitions under load
- Failover scenarios with leader election
Test Data Management Strategy
- Fixtures: Define reusable test data sets for job states, schedules, and execution history using JSON/YAML files
- Factories: Implement factory classes for generating test job configurations with various schedule types and failure modes
- Seeding: Use database migration scripts to populate test environments with realistic job data and historical execution records
- Isolation: Ensure each test runs in isolated database transactions or clean test databases to prevent cross-contamination
13. Observability Design
Structured Logging Schema
All logs must follow a consistent schema to enable effective querying and analysis:
{
"timestamp": "2023-10-27T14:30:45.123Z",
"level": "INFO|WARN|ERROR",
"service": "cron-scheduler",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user-123",
"job_id": "job-456",
"operation": "scheduleJob",
"message": "Job scheduled successfully",
"duration_ms": 15,
"error_code": "JOB_ALREADY_EXISTS",
"error_stack": "...",
"job_details": {
"cron_expression": "0 0 * * *",
"target_endpoint": "/api/webhook",
"retry_count": 3
}
}
PII masking rules:
- User IDs, email addresses, and personal identifiers are masked in logs
- Sensitive fields like API keys or tokens are redacted (show as
***)
Distributed Tracing Instrumentation
Using OpenTelemetry for distributed tracing:
Custom spans to add:
cron.job.scheduled- when a job is added to schedulercron.job.executed- when a job runs successfully or failscron.job.rescheduled- when job is rescheduled due to failurecron.job.deleted- when job is removed from scheduler
Attributes captured per key operation:
Span span = tracer.spanBuilder("cron.job.executed")
.setAttribute("job.id", job.getId())
.setAttribute("job.cron_expression", job.getCronExpression())
.setAttribute("job.target_endpoint", job.getTargetEndpoint())
.setAttribute("job.execution_count", job.getExecutionCount())
.setAttribute("job.status", job.getStatus().toString())
.startSpan();
Metrics Design
RED Method metrics (Rate, Errors, Duration) with appropriate labels:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
cron_scheduler_jobs_submitted_total | Counter | job_id, status | Total jobs submitted |
cron_scheduler_jobs_executed_total | Counter | job_id, status, error_code | Jobs executed (success/failure) |
cron_scheduler_jobs_execution_duration_seconds | Histogram | job_id, status | Execution time per job |
cron_scheduler_jobs_failed_total | Counter | job_id, error_code | Failed job executions |
cron_scheduler_jobs_queue_size | Gauge | Current queue size | |
cron_scheduler_jobs_active_count | Gauge | Active job runners |
Example metric usage:
@MicrometerCounter(name = "cron_scheduler_jobs_executed_total",
labels = {"job_id", "status"})
public void incrementJobExecution(String jobId, JobStatus status) {
// implementation
}
Health Check Endpoint Design
Endpoints with their checks:
| Endpoint | Purpose | Checks Performed |
|---|---|---|
/health | Overall system health | DB connectivity, cache availability |
/ready | Readiness for traffic | Scheduler state, queue health |
/live | Liveness probe | Process health, memory usage |
/startup | Startup validation | Config loaded, dependencies initialized |
Sample response format:
{
"status": "healthy",
"timestamp": "2023-10-27T14:30:45.123Z",
"components": {
"database": {
"status": "healthy",
"latency_ms": 25
},
"cache": {
"status": "healthy",
"connected": true
},
"scheduler": {
"status": "healthy",
"active_jobs": 123
}
}
}
Alerting Thresholds
Critical alerts (escalation to on-call):
| Metric | Warning Level | Critical Level | Alerting Channel |
|---|---|---|---|
cron_scheduler_jobs_failed_total | >5 failures in 5m | >20 failures in 5m | Slack + PagerDuty |
cron_scheduler_jobs_execution_duration_seconds | P95 > 5s | P95 > 30s | Slack + PagerDuty |
cron_scheduler_jobs_queue_size | >1000 jobs | >5000 jobs | Slack + PagerDuty |
cron_scheduler_jobs_executed_total | 0 jobs in 1h | 0 jobs in 5h | Slack + PagerDuty |
Warning alerts (monitoring only):
| Metric | Warning Level | Alerting Channel |
|---|---|---|
cron_scheduler_jobs_failed_total | >1 failure in 5m | Slack |
cron_scheduler_jobs_execution_duration_seconds | P95 > 2s | Slack |
cron_scheduler_jobs_queue_size | >500 jobs | Slack |
On-call escalation path:
- Slack alert (5 min)
- Email notification (10 min)
- PagerDuty page (30 min)
- Escalation to team lead (1 hour)
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Job execution granularity: Should we support sub-minute scheduling (e.g., every 30 seconds) or limit to minute-level precision? This affects system complexity and resource allocation.
- Global vs. regional scheduling: Should all jobs execute in a single time zone or support per-job time zones? This impacts data model complexity and user experience.
- Job dependency management: Do we need to support job chaining or dependency graphs? This would require a more complex orchestration layer.
- Resource allocation per job: Should jobs have configurable resource limits (CPU, memory) or execute with uniform resource allocation?
Known trade-offs and justifications
- Single-node vs. distributed execution: We're designing for single-node operation to reduce complexity, but this creates a SPOF. For production use, we'd need to implement distributed job execution with leader election and coordination.
- In-memory vs. persistent state: Using in-memory job state for scheduling decisions reduces I/O but makes the system non-reentrant. We trade availability for simplicity.
- Eventual consistency vs. strong consistency: Job state updates are eventually consistent across nodes in a distributed system, which may cause race conditions in high-concurrency scenarios.
- High availability vs. low latency: Prioritizing fast job execution over system-wide availability in case of node failures.
- Simplicity vs. feature completeness: Limiting to basic cron expressions and ignoring advanced features like job clustering or complex retry policies to maintain implementation simplicity.
Risks and mitigation strategies
- Job drift: Jobs may drift from their intended schedule due to system load or processing delays. Mitigation: Implement job execution time tracking and alerting for jobs that exceed their scheduled window.
- System downtime: If the scheduler service crashes, all pending jobs are lost. Mitigation: Implement job persistence to disk or database with recovery mechanisms.
- Resource exhaustion: A single job could consume all system resources, starving other jobs. Mitigation: Implement resource quotas and job lifecycle management with timeout enforcement.
- Clock synchronization issues: Jobs may execute at incorrect times if system clocks are not synchronized. Mitigation: Implement clock drift detection and alerting, or use a centralized time source.
- Race conditions in job state updates: Concurrent updates to job status could cause inconsistency. Mitigation: Use atomic operations or database-level locking for critical state transitions.
Technical debt acknowledged
- Limited job history tracking: We don't implement detailed job execution history or metrics storage, which would be needed for production monitoring and debugging.
- No job versioning: Jobs cannot be versioned or rolled back, making it difficult to handle breaking changes in job definitions.
- Hardcoded configuration limits: Parameters like max concurrent jobs or retry limits are hardcoded rather than configurable, requiring code changes for tuning.
- No job prioritization: All jobs are treated equally, which could cause important jobs to be starved by lower-priority ones.
- Simplified error handling: Error recovery is basic, with no sophisticated retry logic or circuit breaker patterns for external job dependencies.
- Monolithic design: The entire scheduler is implemented in a single service, which makes it hard to scale or isolate failures.
- No job input/output validation: Jobs can execute with malformed inputs without proper validation, leading to runtime errors that are hard to debug.
- Limited concurrency control: The current design doesn't support fine-grained locking or semaphore-based job execution limits, which could lead to resource contention in high-concurrency environments.