Files
spring-async-demo/scheduling/docs/04-clock-skew.md
Ankur Mhatre 6af6e4a3f1 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:28:06 +05:30

2.6 KiB

prev: One scheduler thread · README · next: When not to use a lock

4. Clock skew, and what usingDbTime() is for

JdbcTemplateLockProvider.Configuration.builder()
        .withJdbcTemplate(new JdbcTemplate(dataSource))
        .usingDbTime()
        .build();

Without usingDbTime(), each replica writes lock_until from its own clock and compares lock_until against its own clock. The lock is then only as good as the agreement between three machines about what time it is.

Three replicas racing is not a proof, because a race can go either way. ClockSkewTest drives the LockProvider directly instead, so the two acquisitions are ordered by the test and the only variable is what the second caller believes the time to be (docs/output/clock-skew.txt):

lockAtMostFor = 30s, both callers ask for the same lock name.

JdbcTemplateLockProvider WITHOUT usingDbTime()
  node with a correct clock  : acquired
  node with a clock 40s fast : ACQUIRED -- two holders at the same time

JdbcTemplateLockProvider WITH usingDbTime()
  node with a correct clock  : acquired
  node with a clock 40s fast : refused

A node whose clock is ahead by more than lockAtMostFor considers every live lock expired. It takes the lock while somebody else is holding it, and the whole mechanism silently stops working — for that node only, which is why it presents as "it usually runs once".

The skew is simulated with ShedLock's own ClockProvider.setClock(...), which is what the provider reads for "now" on the non-database path. That is also the cheapest way to test this on your own code: no NTP fiddling and no root.

usingDbTime() moves both the write and the comparison into a SQL statement, so there is exactly one clock in the system. It is supported on PostgreSQL, MySQL, MariaDB, MS SQL, Oracle, DB2, HSQL and H2.

Use it. Forty seconds of skew is not exotic — a VM resuming from a snapshot, a container on a host with a broken NTP client, or a laptop that just woke up will all do it, and cloud instances drift more than people expect.

What it does not fix

usingDbTime() makes expiry decisions consistent. It does not shorten the window created by a crash: if a holder dies, the lock still stays held until lockAtMostFor elapses. Nor does it help if your lockAtMostFor is shorter than the job — in that case the lock expires legitimately, a second replica starts, and both run. Size lockAtMostFor against the worst case, not the average.

next: When not to use a lock