×
Back to book

Custom Scheduled Tasks with SpringBot

This article explores how to create scheduled tasks.

SpringBot supports the creation of scheduled tasks to complete jobs at a specific time or frequency.

For this, we use the Quartz Job Scheduling Library to enable us to support a larger range of jobs, from simple to complex.

This guide will introduce you to how the the job scheduling works as well as how to create your own custom job.

Configuration

Quartz configuration is defined in two places. They are both found under serverside/src/main/resources/

Location
quartz/quartz.properties You won't need to modify this for the most part, but if you do, the configurable features can be found in the Quartz documentation.
application-default.properties Contains Spring-specific configurations for the Quartz scheduler. To modify these, override them in your profile of choice, e.g. serverside/src/main/resources/application-dev.properties

Basic Concepts

A trigger defines the when, a job defines the how, and a job detail defines the what.

Trigger

A job trigger is the event that causes the job to run. This can be a simple trigger or a cron trigger.

Simple Trigger: A trigger that is based upon a frequency, e.g runs every two minutes.

Cron Trigger: A trigger that can be defined by a cron expression. This enable more complex scheduling, e.g. run at 2pm every second day.

Job

A job is the action that runs when the trigger fires. This can be as simple as logging "Hello World", to something as complex as running an import once a day or updating the status of records based upon some expiry dates.

Job Detail

Whereas a trigger defines the when, a job defines the how, a job detail defines the what. A job detail consists of a job and a trigger.

Task

In the context of this article, we refer to a task as the collection of job, trigger and job detail.

Getting Started

We will be using the dev profile for the purpose of this lesson.

Enable your job scheduling.

  1. Open serverside/src/main/resources/application-default.properties

  2. Find the config option that looks like the following:

     # % protected region % [Disable or enable quartz here] off begin
     quartz.enabled=false
    
     # % protected region % [Disable or enable quartz here] end
  3. Turn on the protected region and set quartz.enabled=true. It should now look like the following:

     # % protected region % [Disable or enable quartz here] on begin
     quartz.enabled=true
    
     # % protected region % [Disable or enable quartz here] end

Any jobs that we create can now run.

Making a Custom Scheduled Task

For this project, we will be continuing with the Codebots Zoo Project, which can be downloaded from the public repository

User Story : As a user, I want the application to automatically set the vet check status based on the date so that I we can have schedulers remind us.
Acceptance Criteria :

  1. If the Last Vet Check was less than 2 weeks ago, we set the Vet Check Status to Complete.
  2. If the Last Vet Check was greater than 2 weeks ago but less than 4, we set the Vet Check Status to Required.
  3. If the Last Vet Check was greater than 4 weeks ago, then we set the Vet Check Status to Overdue.

Create the Job

The job is the key piece of logic that defines what is completed when a trigger fires.

For this job, we are going to set it to update the Vet Check Status based on the Last Vet Check date.

Files to change or to create:

File Name Description
AnimalRepository DAO layer of the animal
AnimalService Service layer of Animal, contains the actually business logic
StatusUpdateJob Service contains the business logic used by the Job
StatusUpdateJob Quartz job class
  1. Open the repository found under serverside/src/main/java/zoo/repositories/AnimalRepository.java called AnimalRepository.java. Turn on the protected region called Import any additional imports here and copy in the following:
import zoo.entities.QAnimalEntity;
import org.apache.commons.collections4.IterableUtils;
  1. Next, turn on the protected region called Add any additional class methods here and copy in the following:
// % protected region % [Add any additional class methods here] on begin
default List<AnimalEntity> findAnimalEntitiesBeforeLastVetCheckAndStatus(@NotNull OffsetDateTime lastModifiedDate, @NotNull VetCheckStatusEnum status) {
    QAnimalEntity animalEntity = QAnimalEntity.animalEntity;
    return IterableUtils.toList(this.findAll(animalEntity.lastVetCheck
            .before(
                    lastModifiedDate
            )
            .and(animalEntity.vetCheckStatus.ne(status)
                    .or(
                            animalEntity.vetCheckStatus.isNull()
                    )
            )
        )
    );
}

default List<AnimalEntity> findAnimalEntitiesAfterLastVetCheckAndStatus(@NotNull OffsetDateTime lastModifiedDate, @NotNull VetCheckStatusEnum status) {
    QAnimalEntity animalEntity = QAnimalEntity.animalEntity;
    return IterableUtils.toList(this.findAll(animalEntity.lastVetCheck
            .after(
                    lastModifiedDate
            )
            .and(animalEntity.vetCheckStatus.ne(status)
                    .or(
                            animalEntity.vetCheckStatus.isNull()
                    )
            )
        )
    );
}

default List<AnimalEntity> findAnimalEntitiesBetweenVetCheckStatusAndStatus(@NotNull OffsetDateTime start,
                                                                 @NotNull OffsetDateTime finish, @NotNull VetCheckStatusEnum status) {
    QAnimalEntity animalEntity = QAnimalEntity.animalEntity;
    return IterableUtils.toList(this.findAll(animalEntity.lastVetCheck
            .between(
                    start,
                    finish
            )
            .and(animalEntity
                    .vetCheckStatus
                    .ne(status)
                    .or(
                            animalEntity.vetCheckStatus.isNull()
                    )
            )
    ));
}
// % protected region % [Add any additional class methods here] end
  1. Open the service found under serverside/src/main/java/zoo/services/AnimalService.java called AnimalService.java. Turn on the protected region called Add any additional class methods here and copy in the following:

    // % protected region % [Add any additional class methods here] on begin
    /**
      * Search for all animals that have a status that is not completed and a
      * VetVisitDate that is less than 2 weeks old.
      *
      * @return List of all Animals that match that are ready to be marked as
      *         completed.
      */
     public List<AnimalEntity> findByReadyToBeCompleted() {
         return this.repository.findAnimalEntitiesAfterLastVetCheckAndStatus(
                 OffsetDateTime.now().minusWeeks(2).withHour(0).withMinute(0).withSecond(0).withNano(0),
                 VetCheckStatusEnum.COMPLETE
         );
     }
    
     /**
      * Search for all animals that have a status that is not overdue and a
      * VetVisitDate that is greater than 4 weeks old.
      *
      * @return List of all Animals that match that are ready to be marked as
      *         overdue.
      */
     public List<AnimalEntity> findByReadyToBeOverdue() {
         return this.repository.findAnimalEntitiesBeforeLastVetCheckAndStatus(
                 OffsetDateTime.now().minusWeeks(4).withHour(0).withMinute(0).withSecond(0).withNano(0),
                 VetCheckStatusEnum.OVERDUE
         );
    
     }
    
     /**
      * Search for all animals that have a status that is not required and a
      * VetVisitDate that is greater than 2 weeks old. but less than 4.
      *
      * @return List of all Animals that match that are ready to be marked as
      *         required.
      */
     public List<AnimalEntity> findByReadyToBeRequired() {
         return this.repository.findAnimalEntitiesBetweenVetCheckStatusAndStatus(
                 OffsetDateTime.now().minusWeeks(4).withHour(0).withMinute(0).withSecond(0).withNano(0),
                 OffsetDateTime.now().minusWeeks(2).withHour(0).withMinute(0).withSecond(0).withNano(0),
                 VetCheckStatusEnum.REQUIRED
         );
    
     }
    // % protected region % [Add any additional class methods here] end
  2. Open the entity found under serverside/src/main/java/zoo/entities/ called AnimalEntity.java . Turn on the protected region called Add any additional class methods here and copy the following into it:

    // % protected region % [Add any additional class methods  here] on begin
    /*
     * Set the vet visit status to overdue
     */
    public void vetVisitOverdue() {
        this.vetCheckStatus = VetCheckStatusEnum.OVERDUE;
    }
    
    /**
     * Set the vet visit status to required
     */
    public void vetVisitRequired() {
        this.vetCheckStatus = VetCheckStatusEnum.REQUIRED;
    }
    
    /**
     * Set the vet visit status to complete
     */
    public void vetVisitComplete() {
        this.vetCheckStatus = VetCheckStatusEnum.COMPLETE;
    }
    // % protected region % [Add any additional class methods  here] end
  3. Create a new file under serverside/src/main/java/zoo/services/jobs called StatusUpdateJobService.java. For this activity, an example job class already exists, and you can add more jobs here if required for future tasks.

     package zoo.services.jobs;
    
     import lombok.extern.slf4j.Slf4j;
     import org.springframework.beans.factory.annotation.Autowired;
     import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
     import org.springframework.stereotype.Service;
     import zoo.services.AnimalService;
     import zoo.configs.security.helpers.AnonymousHelper;
     import zoo.entities.AnimalEntity;
    
     @Service
     @ConditionalOnProperty(name = "quartz.enabled")
     @Slf4j
     public class StatusUpdateJobService implements JobService {
    
         private final AnimalService animalService;
    
         @Autowired
         public StatusUpdateJobService(AnimalService animalService) {
             this.animalService = animalService;
         }
    
         public void executeJob() {
             /* 
              * The anonymous helper is a tool that 
              * allows us to bypass any security restrictions 
              * which is useful as a scheduled task has not got 
              * an authentication profile.
              * */
             AnonymousHelper.runAnonymously(() -> {
                 processOverdueVetVisitsForAnimals();
                 processCompleteVetVisitsForAnimals();
                 processRequiredVetVisitsForAnimals();
             });
         }
    
         /**
          * Set VetVisitStatus of animals with an overdue vet visit
          */
         private void processOverdueVetVisitsForAnimals() {
             var animals = animalService.findByReadyToBeOverdue();
             animals.forEach(AnimalEntity::vetVisitOverdue);
             animalService.saveAll(animals);
    
             log.info("Marked {} as overdue", animals.size());
         }
    
         /**
          * Set VetVisitStatus of animals with an complete vet visit
          */
         private void processCompleteVetVisitsForAnimals() {
             var animals = animalService.findByReadyToBeCompleted();
             animals.forEach(AnimalEntity::vetVisitComplete);
             animalService.saveAll(animals);
    
             log.info("Marked {} as complete", animals.size());
         }
    
         /**
          * Set VetVisitStatus of animals with an required vet visit
          */
         private void processRequiredVetVisitsForAnimals() {
             var animals = animalService.findByReadyToBeRequired();
             animals.forEach(AnimalEntity::vetVisitRequired);
             animalService.saveAll(animals);
    
             log.info("Marked {} as required", animals.size());
         }
    
     }

    We create a separate Service and JobService to separate the logic. The Service is just the business logic for that entity, like the AnimalService is just for the business logic for Animal. Job service relates to the business logic for a certain Job. This means your code for Job and Entity are decoupled from each other.

  4. Finally, create the Job class. Create a new file in serverside/src/main/java/zoo/jobs called StatusUpdateJob.java. You can make additional jobs by creating more job classes.


package zoo.jobs;

import lombok.Getter;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import zoo.services.jobs.StatusUpdateJobService;

@Component
@ConditionalOnProperty(name = "quartz.enabled")
@DisallowConcurrentExecution
public class StatusUpdateJob extends AbstractJob {

    @Getter
    private final static String description = "Update the status of animals according to the data";

    @Getter
    private final static String name = "Status Update Job";

    @Autowired
    private StatusUpdateJobService jobService;

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        jobService.executeJob();
    }
}

This file is where we can define a description and name for the job. We can additionally link our service here. For details on the@DisallowConcurrentExecution annotation please see the Quartz documentation.

Considering the life cycle of the Job, we put the most of the business logic into the JobService. The Job simply invokes the method from the JobService

Complete the Task

Now that we have a job, we need to create job detail and job trigger to complete the task.

Job Detail

  1. Open the file located at serverside/src/main/java/zoo/configs/quartz/SchedulerConfig.java and locate the job detail. Turn on the protected region Add any additional imports here, and add the following code:
// % protected region % [Add any additional imports here] on begin
import zoo.jobs.StatusUpdateJob; // required for Simple Job Trigger
import zoo.jobs.SimpleJob; // required for Cron Job
// % protected region % [Add any additional imports here] end
  1. Turn on the protected region Add any additional imports here, and add the following code:
    @Bean
    public JobDetailFactoryBean statusUpdateJobDetail() {
     return JobHelpers.createJobDetail(
         StatusUpdateJob.class,
         StatusUpdateJob.getName(),
         StatusUpdateJob.getDescription()
     );
    }

In the above, StatusUpdateJob.class links to our job class created in step x above. "StatusUpdateJob" refers to the job name and StatusUpdateJob.getDescription() sets the description of the job.

Job Trigger

There are two ways we can do this.

  1. Simple Job Trigger
  2. Cron Trigger

Simple Job Trigger

  1. Open the file located at serverside/src/main/java/zoo/configs/quartz/SchedulerConfig.java and locate the protected region Add trigger and job details here at the bottom of the file. Turn on the protected region and put the following code into the protected region. The frequency is every two minutes.

     @Bean
     public SimpleTriggerFactoryBean statusUpdateJobTrigger(@Qualifier("statusUpdateJobDetail") JobDetail jobDetail) {
     return JobHelpers.createSimpleTrigger(jobDetail, Frequency.MINUTE.getMillis() * 2); // Run every 2 minutes
     }

    The Frequency.java file in the same directory stores an enum with useful values for frequency.

    Another important item to keep in mind is that the bean name of the Job Detail has to match the Qualifier in the parameter of the of the trigger method. This is to find the specific bean for the job detail in the container.

  2. Now start your application. Any animals that you create will automatically have their status changed based on the rules we laid out above.

Cron Job

  1. Open the file located at serverside/src/main/java/zoo/configs/quartz/SchedulerConfig.java and locate the protected region at the bottom of the file, it will look like the following

    // % protected region % [Add trigger and job details here] off begin
    @Bean
    public JobDetailFactoryBean simpleJobDetail() {
        return JobHelpers.createJobDetail(
                SimpleJob.class,
                "SimpleJob",
                SimpleJob.getDescription()
        );
    }
    
    @Bean
    public SimpleTriggerFactoryBean simpleJobTrigger(@Qualifier("simpleJobDetail") JobDetail jobDetail) {
        return JobHelpers.createSimpleTrigger(jobDetail, Frequency.MINUTE.getMillis() * 2); // Run every two minutes
    }
    // % protected region % [Add trigger and job details here] end

    You will notice that there is code already in this protected region, a job detail (simpleJobDetail) and a job trigger (simpleJobTrigger).

  2. Turn on the protected region and put the following code into the protected region

    @Bean
     public CronTriggerFactoryBean statusUpdateJobMidnightTrigger(@Qualifier("statusUpdateJobDetail") JobDetail jobDetail) {
         return JobHelpers.createCronTrigger(jobDetail, CronFrequency.MIDNIGHT.getExpression());
     }

    It is worth noting that Frequency.java in the same directory stores an enum with useful values for frequency. You could defined other useful value of frequency in the Frequency.java file/

    Another important item to keep in mind is that the bean name of the Job Detail has to match the Qualifier in the parameter of the of the trigger method.

    To help create the Cron Expression, you can use the too in Cron Expression Generator

  3. Now start your application. Any animals that you create will automatically have their status changed based on the rules we laid out above once a day at midnight.

Solution

Related Article

Related Lessons

Back to the Techies Qualification