Files
spring-async-demo/scheduling
Ankur Mhatre eaa8171966 Add the scheduling module
Three replicas of one application against one PostgreSQL database, proving
duplicate @Scheduled execution and then removing it with ShedLock: 24
executions where 8 were due, then 7 for 7 ticks.

Also measured: @SchedulerLock without @EnableSchedulerLock does nothing and
warns about nothing; spring.task.scheduling.pool.size=1 does not starve a
fixedRate job but delays it and fires 35 of 40 executions in a burst; and a
node whose clock is 40 seconds fast takes a live lock unless the provider
uses usingDbTime().
2026-09-01 23:32:16 +05:30
..
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30
2026-09-01 23:32:16 +05:30

scheduling

Companion project for @Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas on ankurm.com.

Three replicas of one Spring Boot application run against one PostgreSQL database, and the job_execution table records who ran what and when. Counting rows is the measurement.

Verified stack

Component Version Source of the number
JDK 25.0.4.1+1 (Temurin) java -version
Spring Boot 4.1.1 maven-metadata.xml on Maven Central
Spring Framework 7.0.9 spring-boot-dependencies-4.1.1.pom
ShedLock 7.9.0 maven-metadata.xml; not managed by Boot, so it is pinned in pom.xml
PostgreSQL 14.24 the broker the transcripts ran against; docker-compose.yml uses 17

Quickstart

scripts/postgres.sh start             # throwaway PostgreSQL 14 on :55432, no Docker, no root
scripts/three-replicas.sh unlocked    # 24 executions where 8 were due
scripts/three-replicas.sh locked      # 7 executions for 7 ticks
mvn test                              # scheduler-pool and clock-skew evidence
scripts/run-all.sh                    # regenerates every docs/output/ file

With Docker instead:

docker compose up --build                    # unlocked
PROFILE=locked docker compose up --build     # locked

Profiles

Profile What is active Result
unlocked @SchedulerLock on the job, and nothing else every replica runs every tick
locked LockConfiguration: @EnableSchedulerLock + a LockProvider with usingDbTime() one execution per tick
appclock the same, with a LockProvider that has no usingDbTime() used by the clock-skew experiment
poolprobe three competing @Scheduled methods used by the scheduler-pool tests

Documentation

Chapter What it settles
01 Three replicas, three executions The failure, and why @SchedulerLock alone does nothing
02 The lock How one row and one conditional UPDATE fix it, and what the two durations mean
03 One scheduler thread pool.size=1: the same execution count, arriving in a burst
04 Clock skew A 40-second-fast node taking a live lock, and usingDbTime() refusing it
05 When not to use a lock Idempotence, Kubernetes CronJob, Quartz, and a pre-ship checklist

Captured output

File What it shows
three-replicas-unlocked.txt 8 + 8 + 8 executions, 9 overlapping pairs, an empty lock table
three-replicas-locked.txt 7 executions, 0 overlaps, one lock row
scheduler-pool-1.txt 40 executions, longest gap 1995 ms, 35 of them in a burst
scheduler-pool-4.txt 41 executions, longest gap 201 ms, no burst
clock-skew.txt Two holders of one lock, and usingDbTime() preventing it
tests.txt The test run behind the last three

A note on the database

scripts/postgres.sh downloads the PostgreSQL 14 .deb packages and unpacks them into target/pg — no Docker, no root, no system-wide install. It exists because the transcripts had to be reproducible on a machine with neither. If you have Docker, docker-compose.yml is simpler.