Lesson 1 : Spring Scheduling Fundamentals

Lesson 1 60 min

Lesson 1 — Spring Scheduling Foundations: Building Your First Scheduler Control Plane

Series: Hands-on System Design with Java Spring Boot: Task Scheduler Implementation (60 Lessons)

Why This Matters

Every system that processes payments overnight, sends digest emails, or refreshes a product catalog depends on a scheduler. Netflix pushes encoding jobs on cron. Uber reconciles ride fares in batch windows. Your phone backs up photos on a fixed interval. The pattern is identical: something must fire at the right time, reliably, without human intervention.

Spring Boot ships with scheduling built in — but built in for one machine. This lesson establishes the foundation layer you will extend across 60 days: heartbeat monitoring, trigger patterns, thread pools, cluster duplication awareness, and the blueprint for a production distributed scheduler.

What makes scheduling a system design problem — not just a coding convenience — is that time itself becomes shared infrastructure. A bug in your REST API might affect one request. A bug in your scheduler can charge ten thousand customers twice, send duplicate emails to every user, or leave stale inventory on a storefront for hours.

Agenda

  1. What task scheduling is and where it lives in system design

  2. Component architecture of the Scheduler Foundation control plane

  3. Spring's @Scheduled trigger modes: fixedRate, fixedDelay, cron

  4. Thread pool customization with ThreadPoolTaskScheduler

  5. Task execution control flow — from trigger to metrics

  6. Scheduled task lifecycle and state transitions

  7. The multi-instance duplication problem

  8. Target architecture for a distributed scheduler

Core Concepts

Scheduling as a Control Plane

Think of a scheduler as an operating system for time. Application code declares what should run and when; the scheduler decides how to dispatch work onto threads. In Spring, @EnableScheduling activates a background registrar that scans beans for @Scheduled methods and registers triggers against an internal TaskScheduler.

Three trigger semantics matter:

ModeFiresReal-world use
fixedRateEvery N ms from startHeartbeats, health probes
fixedDelayN ms after previous finishCleanup that must not overlap
cronCalendar expressionReports at 2 AM, invoice cycles

The distinction is subtle but consequential. fixedRate measures from the start of each interval — if a task runs long, the next invocation may queue up immediately after it finishes, potentially stacking executions. fixedDelay waits until the task completes, then starts the countdown — ideal when overlapping runs would corrupt shared state. cron expresses calendar intent: "every Monday at 03:00 UTC," which is how billing cycles and compliance reports are defined in production.

Stripe's reconciliation jobs use cron-like windows. Datadog agents use fixedRate heartbeats. Log compaction in Kafka uses fixedDelay so a slow run does not stack.

Thread Pools Are Throughput Levers

By default, Spring uses a single-thread scheduler. One slow task blocks everything else — unacceptable at scale. ThreadPoolTaskScheduler with a custom pool (10 core, 20 max in our project) lets concurrent tasks run in parallel. Wiring it through SchedulingConfigurer replaces the default registrar — the same pattern used inside large e-commerce flash-sale engines where hundreds of micro-tasks fire per second.

Without a pool, your health probe waits while a report generation task holds the only thread for thirty seconds. With a pool, each scheduled method gets its own worker when the trigger fires. The trade-off: more threads mean more memory and more concurrent access to shared resources.

The Duplication Trap

Deploy two instances behind a load balancer and every @Scheduled method runs twice. No shared state, no coordination. Instance A and Instance B both charge customers, both generate reports. Our cluster simulation launches two JVMs with distinct instance IDs, persists executions to H2, and surfaces duplicate counts on the dashboard — the same failure mode that burned teams at early-stage fintech startups before they added distributed locks.

This is not a Spring bug. It is the correct behavior of independent clocks. Each JVM has its own scheduler thread, its own trigger registry, and zero awareness of peer nodes. The fix — leader election, distributed locks, or message-queue handoff — comes in later lessons. This makes the problem visible so you never forget it exists.

Component Architecture

Component Architecture

Scheduler Control Plane Architecture UI & API LAYER RUNTIME LAYER DEPLOYMENT & LOCK LAYER Dashboard UI Thymeleaf Console REST API Programmatic Control Task Registry In-Memory Definitions Scheduler Core @Scheduled Registrar Thread Pool ThreadPoolTaskScheduler Execution Store H2 / Audit Trail Log Instance A JVM Node 1 Instance B JVM Node 2 Lock Service [PLANNED - Redis/DB]

UI / API layer: sit at the top — the human-facing and programmatic entry points:

  • Dashboard — the live web UI where operators watch heartbeats, thread utilization, duplicate counts, and the architecture roadmap. In production systems like Airflow or Temporal, this role is filled by a web console; here it is a lightweight Thymeleaf dashboard.

  • REST API — programmatic access to the same data. Other services, CI pipelines, or monitoring tools call these endpoints without loading the UI.

  • Task Registry — an in-memory catalog of task definitions (name, trigger type, expression, lifecycle).

Runtime layer: do the actual work:

  • Thread Pool — the worker fleet inside a single JVM. When a trigger fires, the pool assigns a thread. Pool size directly caps how many scheduled tasks can run simultaneously on one node.

  • Scheduler Core — the brain. It hosts @Scheduled method registration, trigger evaluation, and dispatch to the thread pool. Everything time-related flows through here.

  • Execution Store — the audit trail. Every cluster-mode run is persisted with instance ID, timestamp, status, and details. This is your forensic log when duplicates appear.

Future layer:

  • Lock Service — not implemented yet, deliberately shown with a dashed border. In next lessons you will add Redis- or database-backed locks so only one instance executes a given task tick. The arrow from Instance B toward Lock Service hints at the coordination path both nodes will eventually use.

Instance A and Instance B at the bottom represent horizontally scaled deployments. Both connect upward to Scheduler Core independently — that is the duplication problem visualized. In a corrected architecture, Lock Service sits between instances and execution, gating who may proceed.

Data flow summary

Code
Operator → Dashboard → REST API → Scheduler Core → Thread Pool → Task execution
                                      ↓
                              Execution Store → Dashboard metrics
                                      ↑
                         Instance A / Instance B (both write, no gate yet)

Task Execution Control Flow

Flowchart

Task Execution Control Flow & Duplication Path Trigger Cron / Rate / Delay Timer Fired Dispatch Event Thread Pool Assign Worker Execute Run @Scheduled Multi- Instance? Persist Log Audit Record Duplicate Run! Uncoordinated Exec Metrics Dashboard Update NO (Single Node) YES Next Schedule Cycle

This flowchart traces what happens from the moment a trigger fires until metrics appear on the dashboard.

Step-by-step walkthrough

  1. Trigger — the starting event. A clock tick (fixedRate), a completion delay (fixedDelay), or a cron match initiates the pipeline. Nothing runs yet; the scheduler has simply decided now is the time.

  2. Timer → Thread Pool → Execute — the horizontal chain across the top is the happy path on a single node. The timer hands off to the pool, the pool assigns a worker thread, and the @Scheduled method body runs. If the pool is saturated, tasks queue inside the pool — visible on the dashboard as rising active thread counts.

  3. Multi? (decision diamond) — the critical fork. After execution completes, the system asks: are multiple instances running? On a single JVM the answer is no, and the flow proceeds straight to Persist Log. In cluster mode the answer is yes — and without a lock, both instances took the execute path independently.

  4. Duplicate Run — the failure branch. When two instances fire the same trigger, both reach Execute. Neither knows the other ran. Billing runs twice. Reports generate twice.

  5. Persist Log → Metrics — regardless of duplication, each execution is recorded in the Execution Store and surfaced as dashboard metrics and Prometheus counters. This is why the cluster simulation panel can count duplicates: the log captures who ran what, even when coordination is missing.

Production insight

The dashed paths looping back toward Trigger represent retry and next-cycle behavior. A failed task does not stay failed forever in a mature scheduler — it re-enters the trigger cycle.

Scheduled Task Lifecycle

State Machine

Scheduled Task Lifecycle State Machine IDLE SCHEDULED RUNNING Pool Queue SUCCESS FAILED tick dispatch done error reset / re-arm for next schedule

Every scheduled task moves through a finite set of states. Understanding this machine is prerequisite to adding retries, timeouts, and idempotency in later lessons.

States explained

StateMeaningWhat happens
IDLETask exists but no trigger is activeBean is registered; waiting for first tick
SCHEDULEDTrigger matched; queued for dispatchEntry sits in the thread pool queue
RUNNINGWorker thread executing method bodyBusiness logic runs; resources are held
SUCCESSCompleted without exceptionDuration recorded; metrics incremented
FAILEDException thrown or timeout hitError logged; retry path may activate

Transitions

  • tick (IDLE → SCHEDULED): the trigger fires. For fixedRate, this happens on a wall-clock interval regardless of previous run duration. For fixedDelay, it happens N milliseconds after the last run finished.

  • dispatch (SCHEDULED → RUNNING): the thread pool assigns a worker. If the pool is full, SCHEDULED persists until a thread frees up — this is backpressure at the scheduling layer.

  • done (RUNNING → SUCCESS): clean completion. The task returns to IDLE via the dashed retry/next-cycle path, ready for the next trigger.

  • error (RUNNING → FAILED): uncaught exception or resource exhaustion. FAILED can transition to FAILED again on repeated errors (the vertical arrow between SUCCESS and FAILED represents monitoring overlap — a task that succeeded once can fail on the next cycle).

Pool Queue

The green Pool Queue box connected to RUNNING represents tasks waiting for threads when the pool is saturated. In our project with six concurrent pool workloads plus three core scheduler tasks, you will see this queue activity on the Thread Pool dashboard panel during high-frequency bursts.

Why this matters at scale

Production schedulers at companies like Shopify and LinkedIn treat state as a first-class database column, not an in-memory afterthought. When you can query "show me all tasks stuck in RUNNING for more than 10 minutes," you have operability. This state machine is the conceptual foundation for that capability.

Real-World Context

SystemScheduling patternLesson connection
AWS EventBridgeCron + rate expressionsSame semantics as @Scheduled(cron) and fixedRate
Kubernetes CronJobOne pod per schedule tickSame duplication risk if multiple controllers run
Quartz SchedulerPersistent job store + triggersPrecursor to our Week 2 TaskDefinition model
Uber Cadence / TemporalDurable workflow timersEvolution path when @Scheduled is no longer enough

The pattern across all of them: trigger → dispatch → execute → record. Spring's @Scheduled is the simplest expression of that pattern. Your 60-day journey expands each stage until the system survives node crashes, network partitions, and ten-million-task bursts.
L

Key Code Snippets

Enable scheduling and custom pool:

java
@SpringBootApplication
@EnableScheduling
public class SchedulerFoundationApplication { ... }

@Configuration
public class FoundationSchedulingConfigurer implements SchedulingConfigurer {
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        registrar.setScheduler(primaryTaskScheduler);
    }
}

Three trigger types in one service:

java
@Scheduled(fixedRate = 5000)
public void healthProbe() { ... }

@Scheduled(fixedDelay = 15000)
public void resourceCleanup() { ... }

@Scheduled(cron = "0 * * * * ?")
public void periodicReport() { ... }

Instance identity for cluster observability:

yaml
app:
  instance:
    id: ${INSTANCE_ID: node-local}

Each JVM sets INSTANCE_ID differently in cluster mode so the Execution Store can attribute duplicates to specific nodes.


Assignment

  1. Add a fourth scheduled task using a cron expression that fires every 30 seconds. Register it in the task registry API.

  2. Change the thread pool size to 5 and observe how highFrequencyPulse execution latency shifts on the dashboard.

  3. Launch cluster mode and record how many BILLING_CYCLE executions occur in 3 minutes with two instances. Calculate the duplication factor.

  4. Sketch (on paper or in the architecture panel) where a Lock Service would sit between the Scheduler Core and Worker Tasks.

Solution Hints

  1. Add @Scheduled(cron = "0/30 * * * * *") in PoolWorkloadService or a new bean; POST a matching entry via /api/tasks.

  2. Set scheduler.pool.size: 5 in application.yml, restart, compare activeThreads and bar chart heights before/after.

  3. Query GET /api/cluster/status on either port; duplicateCounts.BILLING_CYCLE divided by expected single-instance count (≈2 in 3 min at 90s rate) gives your factor — expect ~2× with two nodes.

  4. Lock Service sits between Scheduler Core and execution dispatch — it gates which instance may enter RUNNING state for a given task tick. On Diagram 1, draw an arrow from Lock Service to both Instance A and Instance B, with only one path open per tick.

Takeaway

You now understand scheduling as a control plane — not a convenience annotation. The three diagrams map directly to the system you built:

  • Component Architecture — what exists today and what comes next

  • Control Flow — how a trigger becomes a metric, and where duplication enters

  • State Machine — the lifecycle every task follows, from IDLE to SUCCESS or FAILED

Questions & Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *