Introduction
In this article, we’ll review a straightforward approach for using Postgres Advisory locks to enable HA scheduled jobs with Springboot.
TL;DR
For the complete example, you can find the source code covered in this blog at GitHub.
Why not Quartz?
The need to configure highly available scheduled tasks in an application is a very common need. One of the more popular java libraries offering a solution is Quartz. Although Quartz has been around for years and has great documentation and wide adoption, I think its fair to say that for simple scheduling tasks, Quartz can be overkill. I would respectfully say that it often adds unnecessary complexity (e.g. an external dependency, configuration/bootstrapping, and extra table setup for JDBC Store)
What are Postgres Advisory locks?
Advisory locks in PostgreSQL offer a “user-defined” locking mechanism that applications can explicitly acquire and release. Unlike traditional locks associated with specific tables or rows, advisory locks are more flexible and are identified by user-provided integers. There two types of advisory locks offered by postgres (session bound and transaction bound). In my approach, I leverage transaction-bound advisory locks using the pg_try_advisory_xact_lock()
method. This means the acquired lock is closely tied to the lifecycle of a database transaction. One of the main benefits of this approach is that the lock is automatically released at the end of the transaction. This approach eliminates the need for explicit lock release, simplifying the workflow and ensuring that locks are always appropriately managed.
For a deeper dive into advisory locks and their capabilities, refer to the authoritative documentation.
Code
JobUtils
package com.dcnoris.jobs;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.sql.Types;
import org.springframework.transaction.annotation.Transactional;
/**
*
* @author dcnorris
*/
@Transactional
@Component
public class JobUtils {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* Tries to acquire an advisory lock in the database using the provided lock key.
*
* @param lockKey The key for the lock.
* @return true if the lock was successfully acquired, false otherwise.
*/
public boolean tryAcquireDatabaseLock(int lockKey) {
int[] argTypes = {Types.INTEGER};
return jdbcTemplate.queryForObject(
"SELECT pg_try_advisory_xact_lock(?);",
new Object[]{lockKey},
argTypes,
Boolean.class
);
}
}
Example of how to use JobUtils
package com.dcnoris.jobs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
*
* @author dcnorris
*/
@Service
@Transactional // ensures this job is run in a transaction
public class ScheduleMetricLoggingService {
private static final Logger LOG = LoggerFactory.getLogger(ScheduleMetricLoggingService.class);
private int ADVISORY_LOCK_ID = 3141592; // first seven digits of π (pi)
@Autowired
private JobUtils jobUtils;
// every minute
@Scheduled(cron = "0 * * * * ?")
public void executeJob() {
if (jobUtils.tryAcquireDatabaseLock(ADVISORY_LOCK_ID)) {
LOG.info("Testing");
try {
Thread.sleep(2000); // a little delay to ensure lock is held long enough for all nodes
} catch (InterruptedException ex) {
LOG.error(ex.getMessage(), ex);
}
} else {
LOG.info("Skipping as this job is running on another node");
}
}
}
The Power of Idempotency
One thing to call out for a more robust real world example would be the many benefits of making all your scheduled jobs idempotent. After aquiring the job’s lock, I would suggest adding an additional check (I’ve often used a job audit table to check if the job has already been executed for the current time frame).
If you make your jobs idempotent by using an audit table to track successful job executions, you can easily and safely recover from an outage that might have prevented a job from running on schedule. Moreover, for systems that scale horizontally across multiple nodes or instances, idempotency adds an additional guarantee that only one node performs the task (e.g. outside the normal locking window), preventing unintentional consequences.