Guide to Scheduler Lock in Spring Boot using ShedLock and DynamoDB

Guide to Scheduler Lock in Spring Boot using ShedLock and DynamoDB

Overview

  • In Spring Boot, @Scheduled annotation on a specific bean's method allows the application logic to be executed at a specific time or periodically. A major issue with @Scheduled is that in a distributed system without its concept, if the application is deployed on n multi-nodes, the same scheduled task is executed simultaneously on all n nodes. In some cases, it's necessary for a particular scheduled task to run on only one node. This article introduces the ShedLock library, which can set up a lock for a specific task to be ignored by other instances during execution.

Creating a DynamoDB Table

  • ShedLock supports various storage options for storing lock information. In this article, we'll use DynamoDB as the storage and create an example. (DynamoDB is chosen for the example as it's the easiest and fastest to set up in an AWS cloud environment.) In the AWS Console or AWS CLI, create a table as shown below. (The table name can be freely chosen, but the partition key must be _id.)
$ aws dynamodb create-table --table-name shedlock-dev --attribute-definitions AttributeName=_id,AttributeType=S --key-schema AttributeName=_id,KeyType=HASH --billing-mode PAY_PER_REQUEST

Environment Variables

  • Add the following to your project or operating environment variables.
# Activate Virtual Threads in JDK 21 and Spring Boot 3.2.1
SPRING_THREADS_VIRTUAL_ENABLED=true
# Specify the table name for use in ShedLock
SHEDLOCK_TABLE=shedlock-dev

build.gradle.kts

  • Add the following content to your project's /build.gradle.kts.
dependencies {
    implementation("net.javacrumbs.shedlock:shedlock-spring:5.13.0")
    implementation("net.javacrumbs.shedlock:shedlock-provider-dynamodb2:5.13.0")
    implementation("software.amazon.awssdk:dynamodb-enhanced:2.25.26")
}

Writing the @EnableScheduling Bean

  • Create the taskScheduler bean to exclusively manage scheduled tasks. There are three options depending on the JDK version.
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.TaskScheduler
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler
import java.util.concurrent.Executors

@Configuration
@EnableScheduling
class SchedulingConfig {

    @Bean
    fun taskScheduler(): TaskScheduler {

        // [Option 1] Activate Virtual Threads in JDK 21
        return SimpleAsyncTaskScheduler().apply {
            this.setVirtualThreads(true)
            this.setTaskTerminationTimeout(30 * 1000)
        }

        // [Option 2] Activate Virtual Threads in JDK 19/20
        return ConcurrentTaskScheduler(
            Executors.newScheduledThreadPool(0, Thread.ofVirtual().factory())
        )

        // [Option 3] Set the number of Platform Threads to 10 in JDK 17 and below
        return ThreadPoolTaskScheduler().apply {
            this.poolSize = 10
        }
    }
}

Writing the DynamoDbClient Bean

  • Having chosen to use DynamoDB, write the DynamoDbClient bean as follows.
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.dynamodb.DynamoDbClient

@Configuration
class DynamoDbConfig {

    @Bean
    fun dynamoDbClient(): DynamoDbClient {

        return DynamoDbClient.builder()
            .region(Region.of("{region}"))
            .build()
    }
}

Writing the @EnableSchedulerLock Bean

  • Now it's time to activate ShedLock with DynamoDB as the data storage.
import net.javacrumbs.shedlock.core.LockProvider
import net.javacrumbs.shedlock.provider.dynamodb2.DynamoDBLockProvider
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.services.dynamodb.DynamoDbClient

@Configuration
@EnableSchedulerLock
class SchedulerShedLockConfig(
    @Value("\${shedlock.table}") val shedLockTable: String
) {
    @Bean
    fun lockProvider(@Qualifier("dynamoDbClient") dynamoDbClient: DynamoDbClient): LockProvider {

        return DynamoDBLockProvider(dynamoDbClient, shedLockTable)
    }
}

Applying the @SchedulerLock to Prevent Duplicate Execution

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

@Component
class ScheduleService {

    // Execute the scheduled task only once in a single node at KST 04:30:00 daily without duplication
    @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul")
    @SchedulerLock(name = "fooSchedule", lockAtLeastFor = "30s", lockAtMostFor = "1m")
    fun fooSchedule() {
        // Content to be executed by the scheduler
    }
}
  • The lockAtMostFor value is used to prevent two specific cases. Firstly, it is used when a job takes longer than expected, and to prevent it from being executed again when its execution cycle comes around during the job execution. Secondly, it is used to prevent a job from never being executed again in case of abnormal termination, such as when a node shuts down for some unknown reason while the job is running. If the job terminates normally, the lockAtMostFor value is ignored. If the total execution time of the job currently running and not yet finished by the time of the next cycle is less than the lockAtMostFor value, the job in the next cycle is ignored, preventing duplicate executions.

  • Due to minor differences in system time across nodes, tasks that end very quickly might run multiple times even with a lock set. To prevent this, lockAtLeastFor can be set to ensure the lock is maintained for the specified time, even if the task ends earlier than that. (This value is ignored if the task exceeds the specified time.)

Dynamically Scheduling Tasks at Runtime

  • Scheduling tasks with the @Scheduled annotation is only possible before building the project. However, tasks can also be scheduled at runtime through code. (In this case, the task will be executed only on the node that scheduled it.)
import org.springframework.scheduling.TaskScheduler
import org.springframework.stereotype.Service
import java.time.Instant

@Service
class FooService(
    private val taskScheduler: TaskScheduler
) {
    ...
    taskScheduler.schedule(
        { // Logic to be scheduled for execution },
        // Schedule the execution for 10 seconds after the current time
        Instant.now().plusSeconds(10)
    )
}

Reference Articles