Files
spring-async-demo/scheduling/docs/01-three-replicas-three-executions.md
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

76 lines
2.7 KiB
Markdown

[README](../README.md) · next: [The lock](02-the-lock.md)
# 1. Three replicas, three executions
`@Scheduled` is a per-JVM timer. It has no idea that other JVMs exist. Scale a deployment to
three replicas and every `@Scheduled` method in the application runs three times per tick, on
three machines, at almost the same instant.
That is the whole problem, and it is worth seeing rather than reading. `scripts/three-replicas.sh`
starts three copies of this application against one PostgreSQL database, lets the 3-second
schedule tick for 30 seconds, and counts.
From [`docs/output/three-replicas-unlocked.txt`](output/three-replicas-unlocked.txt):
```
-- executions per replica --
instance_id | count
-------------+-------
replica-1 | 8
replica-2 | 8
replica-3 | 8
-- pairs of executions that overlapped --
overlapping_pairs
-------------------
8
first | first_at | second | second_at
-----------+--------------+-----------+--------------
replica-1 | 22:48:54.564 | replica-3 | 22:48:54.752
replica-1 | 22:48:57.551 | replica-3 | 22:48:57.736
replica-1 | 22:49:00.551 | replica-3 | 22:49:00.736
```
Twenty-four executions where eight were due, and eight of them started while another replica was
still inside the method — 188 milliseconds apart in the first pair, against a job that only takes
200 milliseconds. Anything in that method which
is not idempotent is now a support ticket: a second invoice email, a double refund, two rows
where the unique constraint you did not add would have saved you.
## The part that makes it hard to spot
`ReportJob` is annotated `@SchedulerLock` in this run. Look at the end of the same transcript:
```
-- the lock row --
name | locked_by | locked_at | lock_until
------+-----------+-----------+------------
(0 rows)
```
The annotation is present, the lock table exists, the application started cleanly, and nothing
was ever locked. `@SchedulerLock` on its own is inert: it needs `@EnableSchedulerLock` to install
the interceptor and a `LockProvider` bean to have somewhere to record the lock. In this module
both live on `LockConfiguration`, which is `@Profile("locked")`.
There is no warning for the missing half. That is the single most important sentence in this
module — an application with the annotation and without the plumbing behaves exactly like an
application with no locking at all, and it looks locked in code review.
## Reproducing it
```bash
scripts/postgres.sh start # a throwaway PostgreSQL 14, no Docker, no root
scripts/three-replicas.sh unlocked
scripts/three-replicas.sh locked
```
or, with Docker:
```bash
docker compose up --build # PROFILE=locked docker compose up --build
```
next: [The lock](02-the-lock.md)