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().
3.5 KiB
3.5 KiB
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.