How to write more stable Scheduled Cron Jobs in Spring

Up until now, I wrote most of my articles for Play Framework, even though I use almost exclusively Spring Boot in my daily job. Well, how about we change things a bit and this time I will be writing about Spring. And what better article than a way to make your code more stable and easier to maintain and debug?

So, Spring has a feature that lets you schedule job executions at certain times or at regular intervals. The @Scheduled annotation will trigger the job based on the CRON configuration. It is easy to implement and can offer a great way of doing cleanup of old entries in the database, aggregating data that was generated thought-out the day, or anything that must be executed without interaction from a user.

A CRON job in Spring usually looks like this:

@Scheduled(cron = "0 0 0 * * *", zone = "UTC") // When to run it, every night at 00:00:00 UTC
public void aggregateData() {
    getTheDataFRomTheDab();
    doCalculations();
    saveResults();
    cleanup();
}

This works really well and should be just fine. However, not all developers handle errors well. Your job may suffer changes in the future and one of those changes may trigger an exception or communication with the database fails or any other problem that can result in the job not running properly. If this happens in production you will need a way of not only being notified of the problem but also of gathering the needed information for debugging purposes.

So, here is a way I try to write Scheduled CRON Jobs in Spring or Spring Boot so that any potential bug or exception gets caught and new jobs are forced to be written in this safe way.

Saver Scheduled Jobs

It all starts with an AbstractJob that all future jobs must extend. This abstract job class has a startJob() method that starts the actual processing, along with a few other things. First, it stores the start timestamp in a variable. Next, it generates an NDC/ID (which also contains the job name) that is pushed in the Thread context.

The actual execution of the processing part is done inside a try-catch block so that any exception that can happen during processing is caught and logged. Finally, it calls a cleanup method and logs the execution time. Lastly, the ThreadContext is cleared so that we leave everything clean and ready to be taken up by another execution thread. With these in mind, we can now have an abstract jobs class that looks something like this:

@Slf4j
public abstract class AbstractJob {
    private final String jobName;

    public AbstractJob(String jobName) {
        this.jobName = jobName;
    }

    public void startJob() {
        final long executionStartTime = System.currentTimeMillis();
        String ndc = this.jobName + "_" + Math.abs(new Random().nextInt());
        ThreadContext.push(ndc);
        ThreadContext.put("START_TIMESTAMP", String.valueOf(executionStartTime));

        log.info(String.format("Started job with name %s under NDC %s", this.jobName, ndc));
        try {
            executeJob();
        } catch (Exception e) {
            log.error("Could not finalize execution of job " + this.jobName, e);
        } finally {
            cleanup();
            final long executionDuration = System.currentTimeMillis() - executionStartTime;
            log.info(String.format("Finished %s job. Execution took %sms", this.jobName, executionDuration));
            ThreadContext.pop();
            ThreadContext.clearMap();
        }
    }

    /**
     * The actual job implementation. Do not execute this method. Use the {@link #startJob()} method instead
     */
    abstract void executeJob();

    void cleanup() {
        log.info("No cleanup needed for job " + this.jobName);
    }

After this, you can write your job by extending the AbstractJob class and implementing the executeJob() method and maybe overwriting the cleanup() method. This does pose one small problem though. Since the start of the job is in the abstract class, you can’t add the @Scheduled annotation. To solve this, you will need another class where your CRON jobs get started. I usually call it JobMaintainer and it holds the annotations. Something like this.

@Configuration
@EnableScheduling
public class JobMaintainer {
    private final MyJobOne myJobOne;
    private final MyJobTwo myJobTwo;

    @Scheduled(cron = "0 0 0 * * *") // Every night at 00:00:00 UTC
    public void runJobOne() {
        myJobOne.startJob();
    }

    @Scheduled(cron = "0 0 2 * * ?", zone = "UTC") // Every night at 02:00:00 UTC
    public void runJobTwo() {
        myJobTwo.startJob();
    }
}

Why use this?

You may be thinking what advantages this offers compared to writing a normal job. Well, the biggest advantage is that it automatically handles errors so that your job finishes gracefully and it also logs the error. Everything that is happening in the job, including errors, is written under a common NDC. This makes things a lot easier when you need to identify problems or doing an analysis of what happened for a job.

Secondly, it forces new or less experienced programmers to use the same template for all jobs, again making things easier when investigating problems as well as when maintaining code.

Another, even though smaller, advantage is that you have a central location where your jobs are scheduled. New people that are interacting with the code will find it easier to find the job they are searching for.


Source link