From 3b8c716e511c1891296e9c3c60993286bc38bbaa Mon Sep 17 00:00:00 2001 From: Robin Wilkins Date: Mon, 28 Sep 2020 17:02:19 +0100 Subject: [PATCH] Add demo of job scheduling using quartz The GreetingController is extended, so it now allows a POST which schedules a job to run at a set frequency. The job just logs the Greeting to the console Quartz is a framework which allows Jobs to be scheduled to run asynchronously, at a fixed frequency or point in time. This is useful if you want to allow the API user to schedule a task to be performed at a set point in the future The job schedule is persisted in an h2 database, so they will live beyond the lifespan of an application run. I.e. the application can be restarted, and the running jobs will restart. The database schema is created using Flyway Jira: n/a --- README.md | 11 +- build.gradle | 7 + .../controllers/GreetingController.java | 67 +++++- .../showcase/controllers/GreetingJob.java | 19 ++ .../wilkins/showcase/services/Greeter.java | 12 + src/main/resources/application.yaml | 16 ++ .../resources/db/migration/V0__add_quartz.sql | 214 ++++++++++++++++++ .../controllers/GreetingControllerTest.java | 17 ++ 8 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/wilkins/showcase/controllers/GreetingJob.java create mode 100644 src/main/java/com/wilkins/showcase/services/Greeter.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/migration/V0__add_quartz.sql diff --git a/README.md b/README.md index 5bdcea9..fe6cb46 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ running server: `curl "http://localhost:8080/greeting?salutation=good%20morning&name=robin"` - ### Docker The application can be built into a Docker image and run on a docker environment: @@ -29,4 +28,12 @@ The application can be built into a Docker image and run on a docker environment docker build -t robjwilkins/showcase . docker run -p 8080:8080 robjwilkins/showcase -``` \ No newline at end of file +``` + +### Add a demo of quartz + +Quartz is a java based framework which provides scheduling functionality. This allows jobs to be +created which will be run at a later point-in-time. + +The job details are persisted in a database. This demo uses an in-memory h2 database. A flyway migration +is also added to create the schema for this database diff --git a/build.gradle b/build.gradle index d93539e..fd6afc8 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,13 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation('org.springframework.boot:spring-boot-starter-test') + implementation("org.springframework.boot:spring-boot-starter-quartz") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation('org.flywaydb:flyway-core') + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + runtimeOnly('com.h2database:h2') } test { diff --git a/src/main/java/com/wilkins/showcase/controllers/GreetingController.java b/src/main/java/com/wilkins/showcase/controllers/GreetingController.java index 38c24c5..97a267e 100644 --- a/src/main/java/com/wilkins/showcase/controllers/GreetingController.java +++ b/src/main/java/com/wilkins/showcase/controllers/GreetingController.java @@ -5,15 +5,37 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.quartz.*; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.UUID; + +import static org.quartz.TriggerBuilder.newTrigger; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.ResponseEntity.noContent; +import static org.springframework.http.ResponseEntity.notFound; @RestController +@RequestMapping(path = "/greeting", produces = APPLICATION_JSON_VALUE) public class GreetingController { - private final Logger log = LoggerFactory.getLogger(GreetingController.class); + private static final Logger log = LoggerFactory.getLogger(GreetingController.class); + private static final String JOB_GROUP = "greeting-jobs"; + + private final Scheduler scheduler; + + public GreetingController(Scheduler scheduler) { + this.scheduler = scheduler; + } - @GetMapping("/greeting") + @GetMapping public Greeting getGreeting(@RequestParam(name = "salutation", required = false, defaultValue = "hello") String salutationParam, - @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam) { + @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam) { log.info("A greeting was requested"); @@ -23,4 +45,43 @@ public Greeting getGreeting(@RequestParam(name = "salutation", required = false, return greeting; } + + @PostMapping + public String postGreeting(@RequestParam(name = "salutation", required = false, defaultValue = "hello") String salutationParam, + @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam, + @RequestParam(name = "durationMins", required = false, defaultValue = "2") String durationMins) throws SchedulerException { + var jobDataMap = new JobDataMap(); + jobDataMap.put("salutation", salutationParam); + jobDataMap.put("name", nameParam); + jobDataMap.put("greeting", Greeting.of(salutationParam, nameParam)); + var id = UUID.randomUUID().toString(); + var jobDetail = JobBuilder.newJob() + .ofType(GreetingJob.class) + .withIdentity(id, JOB_GROUP) + .withDescription("Send Greeting Job") + .usingJobData(jobDataMap) + .requestRecovery() + .build(); + var startAt = Timestamp.valueOf(LocalDateTime.from( + new Date().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime()) + .plusMinutes(1)); + var trigger = newTrigger() + .forJob(jobDetail) + .withIdentity(jobDetail.getKey().getName(), "greeting-triggers") + .withDescription("Send Greeting Trigger") + .startAt(startAt) + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withRepeatCount(0)) + .build(); + log.info("scheduling job to run in 1 minutes time"); + scheduler.scheduleJob(jobDetail, trigger); + return id; + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteGreeting(@PathVariable String id) throws SchedulerException { + log.info("deleting job: {}", id); + return scheduler.deleteJob(new JobKey(id, JOB_GROUP)) ? noContent().build() : notFound().build(); + } } diff --git a/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java b/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java new file mode 100644 index 0000000..a229c54 --- /dev/null +++ b/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java @@ -0,0 +1,19 @@ +package com.wilkins.showcase.controllers; + +import com.wilkins.showcase.services.Greeter; +import org.quartz.Job; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public record GreetingJob(Greeter greeter) implements Job { + + private static final Logger log = LoggerFactory.getLogger(GreetingJob.class); + @Override + public void execute(JobExecutionContext context) { + JobDataMap jobDataMap = context.getMergedJobDataMap(); + log.info("******** {}, {} *************", context.getJobDetail().getKey().getName(), + greeter.greet((Greeting) jobDataMap.get("greeting"))); + } +} diff --git a/src/main/java/com/wilkins/showcase/services/Greeter.java b/src/main/java/com/wilkins/showcase/services/Greeter.java new file mode 100644 index 0000000..f96cb3f --- /dev/null +++ b/src/main/java/com/wilkins/showcase/services/Greeter.java @@ -0,0 +1,12 @@ +package com.wilkins.showcase.services; + +import com.wilkins.showcase.controllers.Greeting; +import org.springframework.stereotype.Component; + +@Component +public class Greeter { + + public String greet(Greeting greeting) { + return greeting.salutation() + " " + greeting.name(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..1dd79f5 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,16 @@ +spring: + quartz: + job-store-type: jdbc + properties: + org: + quartz: + jobStore: + driverDelegateClass: "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate" + wait-for-jobs-to-complete-on-shutdown: true + jdbc: + initialize-schema: never + + datasource: + url: jdbc:h2:mem:testdb + username: sa + password: password \ No newline at end of file diff --git a/src/main/resources/db/migration/V0__add_quartz.sql b/src/main/resources/db/migration/V0__add_quartz.sql new file mode 100644 index 0000000..60c6904 --- /dev/null +++ b/src/main/resources/db/migration/V0__add_quartz.sql @@ -0,0 +1,214 @@ +create table qrtz_job_details +( + sched_name varchar(120) not null, + job_name varchar(200) not null, + job_group varchar(200) not null, + description varchar(250), + job_class_name varchar(250) not null, + is_durable boolean not null, + is_nonconcurrent boolean not null, + is_update_data boolean not null, + requests_recovery boolean not null, + job_data bytea, + constraint qrtz_job_details_pkey + primary key (sched_name, job_name, job_group) +); + +create index idx_qrtz_j_req_recovery + on qrtz_job_details (sched_name, requests_recovery); + +create index idx_qrtz_j_grp + on qrtz_job_details (sched_name, job_group); + +create table qrtz_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + job_name varchar(200) not null, + job_group varchar(200) not null, + description varchar(250), + next_fire_time bigint, + prev_fire_time bigint, + priority integer, + trigger_state varchar(16) not null, + trigger_type varchar(8) not null, + start_time bigint not null, + end_time bigint, + calendar_name varchar(200), + misfire_instr smallint, + job_data bytea, + constraint qrtz_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_triggers_sched_name_job_name_job_group_fkey + foreign key (sched_name, job_name, job_group) references qrtz_job_details +); + +create index idx_qrtz_t_j + on qrtz_triggers (sched_name, job_name, job_group); + +create index idx_qrtz_t_jg + on qrtz_triggers (sched_name, job_group); + +create index idx_qrtz_t_c + on qrtz_triggers (sched_name, calendar_name); + +create index idx_qrtz_t_g + on qrtz_triggers (sched_name, trigger_group); + +create index idx_qrtz_t_state + on qrtz_triggers (sched_name, trigger_state); + +create index idx_qrtz_t_n_state + on qrtz_triggers (sched_name, trigger_name, trigger_group, trigger_state); + +create index idx_qrtz_t_n_g_state + on qrtz_triggers (sched_name, trigger_group, trigger_state); + +create index idx_qrtz_t_next_fire_time + on qrtz_triggers (sched_name, next_fire_time); + +create index idx_qrtz_t_nft_st + on qrtz_triggers (sched_name, trigger_state, next_fire_time); + +create index idx_qrtz_t_nft_misfire + on qrtz_triggers (sched_name, misfire_instr, next_fire_time); + +create index idx_qrtz_t_nft_st_misfire + on qrtz_triggers (sched_name, misfire_instr, next_fire_time, trigger_state); + +create index idx_qrtz_t_nft_st_misfire_grp + on qrtz_triggers (sched_name, misfire_instr, next_fire_time, trigger_group, trigger_state); + +create table qrtz_blob_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + blob_data bytea, + constraint qrtz_blob_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_blob_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_calendars +( + sched_name varchar(120) not null, + calendar_name varchar(200) not null, + calendar bytea not null, + constraint qrtz_calendars_pkey + primary key (sched_name, calendar_name) +); + +create table qrtz_cron_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + cron_expression varchar(120) not null, + time_zone_id varchar(80), + constraint qrtz_cron_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_cron_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_fired_triggers +( + sched_name varchar(120) not null, + entry_id varchar(95) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + instance_name varchar(200) not null, + fired_time bigint not null, + sched_time bigint not null, + priority integer not null, + state varchar(16) not null, + job_name varchar(200), + job_group varchar(200), + is_nonconcurrent boolean, + requests_recovery boolean, + constraint qrtz_fired_triggers_pkey + primary key (sched_name, entry_id) +); + +create index idx_qrtz_ft_trig_inst_name + on qrtz_fired_triggers (sched_name, instance_name); + +create index idx_qrtz_ft_inst_job_req_rcvry + on qrtz_fired_triggers (sched_name, instance_name, requests_recovery); + +create index idx_qrtz_ft_j_g + on qrtz_fired_triggers (sched_name, job_name, job_group); + +create index idx_qrtz_ft_jg + on qrtz_fired_triggers (sched_name, job_group); + +create index idx_qrtz_ft_t_g + on qrtz_fired_triggers (sched_name, trigger_name, trigger_group); + +create index idx_qrtz_ft_tg + on qrtz_fired_triggers (sched_name, trigger_group); + +create table qrtz_locks +( + sched_name varchar(120) not null, + lock_name varchar(40) not null, + constraint qrtz_locks_pkey + primary key (sched_name, lock_name) +); + +create table qrtz_paused_trigger_grps +( + sched_name varchar(120) not null, + trigger_group varchar(200) not null, + constraint qrtz_paused_trigger_grps_pkey + primary key (sched_name, trigger_group) +); + +create table qrtz_scheduler_state +( + sched_name varchar(120) not null, + instance_name varchar(200) not null, + last_checkin_time bigint not null, + checkin_interval bigint not null, + constraint qrtz_scheduler_state_pkey + primary key (sched_name, instance_name) +); + +create table qrtz_simple_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + repeat_count bigint not null, + repeat_interval bigint not null, + times_triggered bigint not null, + constraint qrtz_simple_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_simple_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_simprop_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + str_prop_1 varchar(512), + str_prop_2 varchar(512), + str_prop_3 varchar(512), + int_prop_1 integer, + int_prop_2 integer, + long_prop_1 bigint, + long_prop_2 bigint, + dec_prop_1 numeric(13,4), + dec_prop_2 numeric(13,4), + bool_prop_1 boolean, + bool_prop_2 boolean, + constraint qrtz_simprop_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_simprop_triggers_sched_name_trigger_name_trigger_grou_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); diff --git a/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java b/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java index 76cb2c2..c949e83 100644 --- a/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java +++ b/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java @@ -1,14 +1,21 @@ package com.wilkins.showcase.controllers; import org.junit.jupiter.api.Test; +import org.quartz.Scheduler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest public class GreetingControllerTest { @@ -16,6 +23,9 @@ public class GreetingControllerTest { @Autowired MockMvc mockMvc; + @MockBean + Scheduler scheduler; + @Test void returnsGreeting() throws Exception { mockMvc.perform(get("/greeting")) @@ -24,4 +34,11 @@ void returnsGreeting() throws Exception { .andExpect(jsonPath("$.name", is("world"))); } + @Test + void scheduleGreeting() throws Exception { + mockMvc.perform(post("/greeting")) + .andExpect(status().isOk()) + .andExpect(content().string(notNullValue())); + verify(scheduler).scheduleJob(any(), any()); + } }