Shedlock Postgre DB Example With Spring Boot - Java Project - Scheduler Lock

1- Introduction

In this article, we will try to provide a problem solution for scheduling jobs problem depending on the Java Spring project. The problem is especially related to performing the same scheduling task as simultaneously in the case of multiple instances.


2- Scenario Example

For example, let's say we have an application and a scheduled task within that. Let's assume this task will run at the time intervals it has determined, and prepare and submit its report. Additionally, this scheduled task is running on multiple applications (multiple servers/multiple instances).

The potential problem we may have is here that if we do not have a "lock" mechanism then the same scheduled task could be executed multiple times due to the multiple instances.
- For instance, if the application prepares a report and sends it via email. Then each one of the multiple instances can process the same job more than one time and may send the same report multiple times.

Depending on the job, it might cause very critical errors. The mechanism we will need for this is the "locking". In other words, the "locking" mechanism is to "lock" the scheduled task in a running instance and prevent other applications from running it.

This will handle to avoid critical errors and conflicts that can occur when multiple instances of an application perform the same scheduling task simultaneously.


3 - Shedlock

We will examine the Shedlock library with PostgreSQL implementation. I have used Shedlock in a few projects.

Note: Normally, I made this example with versions of Java 8 - 11, Spring Boot 2.x and Shedlock 4.x before. But, I now tested it with Java 17, Spring Boot v3+, Shedlock v5+ versions and everything worked fine as well.

Note 2: Spring Boot V3 requires min JDK 17 and Shedlock V5+ version requires min JDK 17 as well as Spring Data 3.x.x.

Shedlock DB Table Columns

  • name: is a unique value for how the lock operation will be recognized by all applications.
  • lock_until: Re-running of other scheduled task methods is blocked, except for the method that is currently running until this time.
  • locked_at: Shows the time when the last locking operation started.
  • locked_by: Shows which machine did the last locking operation.

@SchedulerLock - Parameter Fields

import net.javacrumbs.shedlock.core.SchedulerLock;


@Scheduled(fixedRate = 1000)
@SchedulerLock(name = "scheduledTaskName", lockAtMostFor = "15m", lockAtLeastFor = "5m")
public void scheduledTask() {
    // do something
}

lockAtMostFor and LockAtLeastFor indicate how long scheduled tasks will remain locked. In other words, they are configurations that specify how long the task will be locked to prevent it from being called by other applications or threads.

To explain them briefly;

LockAtMostFor : It is the locked duration that the method remains locked while the associated scheduled task is running.
- For example, let's say we set lockAtMostFor as 45 minutes and the corresponding job method takes 5 minutes to run. The method with this configuration will be locked for 45 minutes while it is running. After the scheduled method is completed, the corresponding value will be updated with the lockAtLeastFor value which is determined by us.

LockAtLeastFor: Specifies how long the scheduled task will remain locked after it ends.
- For example, let's assume that we give the relevant parameter value for 15 minutes. The running method starts at 00:00 and takes time for 5 minutes. When this scheduled method is completed, the lock_until value will be "00:15".

The point to be noted here is that "15 minutes" is added based on the method start time, not the completion time of the scheduled method.


To sum it up with an Example;

  1. The scheduled method starts at "00:00" and takes time for approximately 5 minutes.
  2. With parameters of lockAtMostFor = "45m", lockAtLeastFor = "10m"
  3. While the scheduled method is processing, the "lock_until" value will be 00:45 with the value of "lockAtMostFor"
  4. When the method is done in 5 minutes, the "lock_until" value will be updated to 00:10 with the value of "lockAtLeastFor"

Note: LockAtLeastFor cannot be greater than lockAtMostFor. It will throw an error.

java.lang.IllegalArgumentException: lockAtLeastFor is longer than lockAtMostFor for lock 'Scheculer_Job'.

Let's understand with code and PostgreDB implementation.


4- Shedlock With PostgreSQL Implementation

Note about DB: I prefer to use PostgreSQL in this example. If you want another DB like MySQL or H2 DB you can use them and configure its data source, etc. yourself.

spring:
  datasource:
    password: postgres
    username: postgres
    url: jdbc:postgresql://localhost:5432/postgres?currentSchema=shed
    platform: postgres
    driverClassName: org.postgresql.Driver
    #initialization-mode: always
    #schema:  classpath:/init.sql, classpath:/a.sql
    #data:  classpath:/create-schema.sql, classpath:/create-shedlock-table.sql
  jpa:
    #defer-datasource-initialization: true
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQL10Dialect
        default_schema: "shed"
        temp:
          use_jdbc_metadata_defaults: false
        format_sql: true
    show-sql: false

I added this dependency too.

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-spring -->
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-spring</artifactId>
            <version>5.7.0</version>
        </dependency>
        <!--https://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-provider-jdbc-template -->
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-provider-jdbc-template</artifactId>
            <version>5.7.0</version>
        </dependency>
    </dependencies>

Note for Create schema: you can use this name or make a different name. (you also need to update the values in the yaml file)

We run the Create Table script for PostgreSQL under the relevant schema.The scripts for other databases are as follows; (quoting from github page)

# Postgres
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));

4.1 - Spring Boot - Shedlock - SchedulerConfiguration

@EnableScheduling Spring framework annotation is added for SpringBootApplication.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

}
Shedlock - LockProvider
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfiguration {
	@Bean
	public LockProvider lockProvider(DataSource dataSource) {
		return new JdbcTemplateLockProvider(
				JdbcTemplateLockProvider.Configuration.builder()
				.withJdbcTemplate(new JdbcTemplate(dataSource))
				.usingDbTime() // Works on Postgres, MySQL, MariaDb, MS															// SQL, Oracle, DB2, HSQL and H2
				.build());
	}
}

@EnableSchedulerLock(defaultLockAtMostFor = "10m") will be a default value for "lockAtMostFor" if it is not configured in @SchedulerLock configuration.

A note was made for UsingDBTime;


"By specifying usingDbTime() the lock provider will use UTC time based on the DB server clock. If you do not specify this option, clock from the app server will be used (the clocks on app servers may not be synchronized thus leading to various locking issues)."

4.2 - Example 1

Let's define a Scheduled method with Shedlock Configuration

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
@Component
public class ShedlockScheduler {

    @Scheduled(fixedRate = 1000)
    @SchedulerLock(name = "Report_Scheduler", lockAtMostFor = "45m", lockAtLeastFor = "10m")
    public void scheduledReport() throws InterruptedException {
        System.out.println("[ScheduledReport][STARTED][TIME]: " + LocalDateTime.now());
        Thread.sleep(10000);
        System.out.println("[ScheduledReport][FINISHED][TIME]: " + LocalDateTime.now());
    }
}
  • We will stop this method with Thread.sleep(10000); for better understanding.
  • In this example, the scheduled method starts at 20:00 UTC. When this method sleeps with Thread.sleep(10000);
  • "lock_until" value is determined by lockAtMostFor parameter value (which is 45 minutes) while the method is running. (Thread.sleep(10000);)

Shedlock's table values are below during the execution time;

When this method is done;

  • "lock_until" value is determined by lockAtLeastFor parameter value (which is 10 minutes) when this scheduled method is done.

Shedlock's table values are below;

The application output is;


4.3 - Example 2

Now, let's look at another example. with Shedlock, we can build a lock mechanism for the different methods in the same application.

Now let's look at an example with and without Shedlock configuration.

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class ShedlockScheduler {

    @Scheduled(cron = "*/1 * * * * *") // every one second
    public void scheduledJob1() {
        System.out.println("[scheduledJob1][FINISHED]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }

    @Scheduled(cron = "*/1 * * * * *") // every one second
    public void scheduledJob2() {
        System.out.println("[scheduledJob2][FINISHED]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }
}

We have two methods that are called every second. When we run them without SchedulerLock configuration, we can see that both methods are running at the same time.

When outputs are examined, it can be seen that they work at the same time as expected.


4.4 - Example 2 with ShedlockConfiguration

Solution with Shedlock

As a solution, we add @SchedulerLock for both methods.

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class ShedlockScheduler {

    @Scheduled(cron = "*/1 * * * * *") // every one second
    @SchedulerLock(name = "Scheculer_Job1", lockAtMostFor = "5s", lockAtLeastFor = "5s")
    public void scheduledJob1() {
        System.out.println("[scheduledJob1][FINISHED]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }

    @Scheduled(cron = "*/1 * * * * *") // every one second
    @SchedulerLock(name = "Scheculer_Job1", lockAtMostFor = "5s", lockAtLeastFor = "5s")
    public void scheduledJob2() {
        System.out.println("[scheduledJob2][FINISHED]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }
}

As we can see that only one method is running at the time and "5 seconds" lock is applied.

In short;

  • The method locked it with the value of lockAtMostFor while the method was running.
  • When the method process was finished then the "lock_until" value was updated with the value of lockAtLeastFor.
  • The methods which are configured via schedulerLock with the value of "name" is "Scheduler_Job1" are not running according to the "lock_until" value.

5- Points to consider - Method Running Time and Locking Values

Firstly, lockAtLeastFor cannot be larger than lockAtMostFor. Secondly, if the scheduled method run/execution time is larger than lockAtMostFor value then the scheduled method might be executed again. This may cause critical errors depending on the what scheduled method do.

Let's look at this with a different example.

Let there be two different running instances, A and B. If the lockAtMostFor and lockAtLeastFor values ​​are not set well, then the run time of the method might be above the locked_until time. Then the application "B" will run the corresponding method before the "A" application finishes its work. As a result, the methods will have worked in two different applications

5.1 - Example

To give an example of this, this time I will run a single method and multiple instances on the same machine (on my local machine).

Let's create a simple scenario for a better understanding of the work done;

Let's create a new table and insert a value in the related schema with PostgreSQL.

CREATE TABLE shed.counting_entity (
	id int8 NOT NULL,
	count int4 NOT NULL,
	CONSTRAINT counting_pkey PRIMARY KEY (id)
);

INSERT INTO shed.counting_entity (id, count) VALUES(1, 0);

Let's create an entity class and its repository

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class CountingEntity {

    @Id
    private Long id;

    @Column(name = "COUNT")
    private int count;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

}
import org.springframework.data.jpa.repository.JpaRepository;

public interface CountingRepository extends JpaRepository<CountingEntity, Long> {

}

5.2 Example - SchedulerLock - Bad Configuration

For example, let's say we have a job that runs every five seconds. (*/5 * * * * *)

Let's define @SchedulerLock configuration with the values of @SchedulerLock(name = "Scheculer_Job_With_ThreadSleep", lockAtMostFor = "4s", lockAtLeastFor = "4s")

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class ShedlockScheduler {

    @Autowired
    private CountingRepository countingRepository;

    @Scheduled(cron = "*/5 * * * * *") // every five second
    @SchedulerLock(name = "Scheculer_Job_With_ThreadSleep", lockAtMostFor = "4s", lockAtLeastFor = "4s")
    public void scheduledWithThreadSleep() throws InterruptedException {

        CountingEntity countingEntity = countingRepository.findById(1l).get();
        Thread.sleep(10000); // 10 seconds
        countingEntity.setCount(countingEntity.getCount() + 1);
        countingRepository.save(countingEntity);
        System.out.println("[AFTER_UPDATE][COUNT_VALUE] : " + countingEntity.getCount() + " [TIME]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }
}

In the method,

  • Let's sleep this method for 10 seconds with Thread.sleep(10000) before our data is fetched and the count value is increased by +1.

I run this application in/as two instances on my local machine.

It may sound a bit confusing, but if you look carefully at the Count value, the method's working times are overlapping and there is a problem.

In this example;

  • We will stop this method with Thread.sleep(10000); for making a problem.
  • Let's assume that the scheduled method starts at 20:00:00 UTC.
    - "locked_until" value will be 20:00:04 in this configuration
    - When this method sleeps 10 seconds with Thread.sleep(10000); another instance's method will also run this method in its own.
  • So the same updated value is processed twice.

5.3 Solution to Bad Config

To solve this problem;

Let's define or change @SchedulerLock configuration with the values of @SchedulerLock(name = "Scheculer_Job_With_ThreadSleep", lockAtMostFor = "20s", lockAtLeastFor = "20s") I updated my count value as "0" (zero) in DB table.

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class ShedlockScheduler {

    @Autowired
    private CountingRepository countingRepository;

    @Scheduled(cron = "*/5 * * * * *") // every five second
    @SchedulerLock(name = "Scheculer_Job_With_ThreadSleep", lockAtMostFor = "30s", lockAtLeastFor = "4s")
    public void scheduledWithThreadSleep() throws InterruptedException {

        CountingEntity countingEntity = countingRepository.findById(1l).get();
        Thread.sleep(10000); // 10 seconds
        countingEntity.setCount(countingEntity.getCount() + 1);
        countingRepository.save(countingEntity);
        System.out.println("[AFTER_UPDATE][COUNT_VALUE] : " + countingEntity.getCount() + " [TIME]: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
    }
}

When the count values and durations are examined then it can be seen that the same method does not process/work at the same time.

In this example;

  • We will stop this method with Thread.sleep(10000); for checking the problem.
  • Let's assume that the scheduled method starts at 20:00:00 UTC.
    - "locked_until" value will be 20:00:30 in this configuration
    - When this method sleeps 10 seconds with Thread.sleep(10000); another instance's method cannot run the same method until the "A" instance's job is done because its "locked_until" time is 20:00:30 while the method is running (lockAtMostFor = "30s")
  • When "A" instance's scheduled method is finished then "locked_until" time will be 20:00:04 (lockAtLeastFor = "4s")
  • So another instance cannot run this method. So the same updated value is not processed twice.

Conclusion

As a result, we examined Shedlock library with Spring + PostgreSQL DB. It is one of the solution approaches as a locking mechanism for asynchronous Java Scheduler Threads or multiple instances.