View Javadoc
1   /*
2    * Copyright (C) 2024 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller.admin;
7   
8   import static java.net.HttpURLConnection.HTTP_ACCEPTED;
9   import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
10  import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
11  
12  import com.fasterxml.jackson.databind.ObjectMapper;
13  import com.fasterxml.jackson.databind.node.ObjectNode;
14  import io.swagger.v3.oas.annotations.Operation;
15  import io.swagger.v3.oas.annotations.media.Content;
16  import io.swagger.v3.oas.annotations.media.Schema;
17  import io.swagger.v3.oas.annotations.responses.ApiResponse;
18  import java.lang.invoke.MethodHandles;
19  import java.util.ArrayList;
20  import java.util.List;
21  import java.util.Objects;
22  import java.util.UUID;
23  import org.quartz.CronTrigger;
24  import org.quartz.InterruptableJob;
25  import org.quartz.JobDataMap;
26  import org.quartz.JobDetail;
27  import org.quartz.JobKey;
28  import org.quartz.Scheduler;
29  import org.quartz.SchedulerException;
30  import org.quartz.Trigger;
31  import org.quartz.TriggerKey;
32  import org.quartz.TriggerUtils;
33  import org.quartz.impl.matchers.GroupMatcher;
34  import org.quartz.spi.OperableTrigger;
35  import org.slf4j.Logger;
36  import org.slf4j.LoggerFactory;
37  import org.springframework.beans.factory.annotation.Autowired;
38  import org.springframework.http.HttpStatus;
39  import org.springframework.http.HttpStatusCode;
40  import org.springframework.http.MediaType;
41  import org.springframework.http.ResponseEntity;
42  import org.springframework.web.bind.annotation.DeleteMapping;
43  import org.springframework.web.bind.annotation.GetMapping;
44  import org.springframework.web.bind.annotation.PathVariable;
45  import org.springframework.web.bind.annotation.PutMapping;
46  import org.springframework.web.bind.annotation.RequestParam;
47  import org.springframework.web.bind.annotation.RestController;
48  import org.springframework.web.server.ResponseStatusException;
49  import org.tailormap.api.repository.SearchIndexRepository;
50  import org.tailormap.api.scheduling.Task;
51  import org.tailormap.api.scheduling.TaskManagerService;
52  import org.tailormap.api.scheduling.TaskType;
53  
54  /**
55   * Admin controller for controlling the task scheduler. Not to be used to create new tasks, adding tasks belongs in the
56   * domain of the specific controller or Spring Data REST API as that requires specific configuration information.
57   * Provides the following endpoints:
58   *
59   * <ul>
60   *   <li>{@link #list /admin/tasks} to list all tasks, optionally filtered by type
61   *   <li>{@link #details /admin/tasks/{type}/{uuid}} to get the details of a task
62   *   <li>{@link #startTask /admin/tasks/{type}/{uuid}/start} to start a task
63   *   <li>{@link #stopTask /admin/tasks/{type}/{uuid}/stop} to stop a task
64   *   <li>{@link #delete /admin/tasks/{type}/{uuid}} to delete a task
65   * </ul>
66   */
67  @RestController
68  public class TaskAdminController {
69    private static final Logger logger =
70        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
71  
72    private final Scheduler scheduler;
73    private final TaskManagerService taskManagerService;
74    private final SearchIndexRepository searchIndexRepository;
75  
76    public TaskAdminController(
77        @Autowired Scheduler scheduler,
78        @Autowired TaskManagerService taskManagerService,
79        @Autowired SearchIndexRepository searchIndexRepository) {
80      this.scheduler = scheduler;
81      this.taskManagerService = taskManagerService;
82      this.searchIndexRepository = searchIndexRepository;
83    }
84  
85    @Operation(
86        summary = "List all tasks, optionally filtered by type",
87        description =
88            """
89  This will return a list of all tasks, optionally filtered by task type.
90  The state of the task is one of the Quartz Trigger states.
91  The state can be one of: NONE, NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED or null in error conditions.
92  """)
93    @GetMapping(path = "${tailormap-api.admin.base-path}/tasks", produces = MediaType.APPLICATION_JSON_VALUE)
94    @ApiResponse(
95        responseCode = "200",
96        description = "List of all tasks, this list may be empty",
97        content =
98            @Content(
99                mediaType = MediaType.APPLICATION_JSON_VALUE,
100               schema =
101                   @Schema(
102                       example =
103                           """
104 {"tasks":[
105 {"uuid":"6308d26e-fe1e-4268-bb28-20db2cd06914","type":"index", "state":"NORMAL", "interruptable": false},
106 {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca8","type":"poc", "state": "BLOCKED", "interruptable": false},
107 {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca9","type":"poc", "state": "PAUSED", "interruptable": false},
108 {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca2","type":"poc", "state": "COMPLETE", "interruptable": false},
109 {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca3","type":"interuptablepoc", "state": "ERROR", "interruptable": true}
110 ]}
111 """)))
112   public ResponseEntity<Object> list(@RequestParam(required = false) String type) throws ResponseStatusException {
113     logger.debug("Listing all tasks (optional type filter: {})", (null == type ? "all" : type));
114     final List<ObjectNode> tasks = new ArrayList<>();
115 
116     final GroupMatcher<JobKey> groupMatcher =
117         (null == type ? GroupMatcher.anyGroup() : GroupMatcher.groupEquals(type));
118     try {
119       scheduler.getJobKeys(groupMatcher).stream()
120           .map(jobKey -> {
121             try {
122               return scheduler.getJobDetail(jobKey);
123             } catch (SchedulerException e) {
124               logger.error("Error getting task detail", e);
125               return null;
126             }
127           })
128           .filter(Objects::nonNull)
129           .forEach(jobDetail -> {
130             Trigger.TriggerState state;
131             try {
132               state = scheduler.getTriggerState(TriggerKey.triggerKey(
133                   jobDetail.getKey().getName(),
134                   jobDetail.getKey().getGroup()));
135             } catch (SchedulerException e) {
136               logger.error("Error getting task state", e);
137               state = null;
138             }
139             tasks.add(new ObjectMapper()
140                 .createObjectNode()
141                 .put(Task.TYPE_KEY, jobDetail.getKey().getGroup())
142                 .put(Task.UUID_KEY, jobDetail.getKey().getName())
143                 .put(
144                     Task.INTERRUPTABLE_KEY,
145                     InterruptableJob.class.isAssignableFrom(jobDetail.getJobClass()))
146                 .put(
147                     Task.DESCRIPTION_KEY,
148                     jobDetail.getJobDataMap().getString(Task.DESCRIPTION_KEY))
149                 .put(
150                     Task.LAST_RESULT_KEY,
151                     jobDetail.getJobDataMap().getString(Task.LAST_RESULT_KEY))
152                 .putPOJO(Task.STATE_KEY, state));
153           });
154     } catch (SchedulerException e) {
155       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting tasks", e);
156     }
157 
158     return ResponseEntity.ok(new ObjectMapper()
159         .createObjectNode()
160         .set("tasks", new ObjectMapper().createArrayNode().addAll(tasks)));
161   }
162 
163   @Operation(
164       summary = "List all details for a given task",
165       description =
166           """
167 This will return the details of the task, including the status, progress,
168 result and any other information.
169 """)
170   @GetMapping(
171       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
172       produces = MediaType.APPLICATION_JSON_VALUE)
173   @ApiResponse(
174       responseCode = "404",
175       description = "Task not found",
176       content =
177           @Content(
178               mediaType = MediaType.APPLICATION_JSON_VALUE,
179               schema = @Schema(example = "{\"message\":\"Task not found\",\"code\":404}")))
180   @ApiResponse(
181       responseCode = "200",
182       description =
183           """
184 Details of the task. The response content will vay according to the type of task,
185 the most common fields are listed in the Task interface.
186 """,
187       content =
188           @Content(
189               mediaType = MediaType.APPLICATION_JSON_VALUE,
190               schema =
191                   @Schema(
192                       example =
193                           """
194 {
195 "type":"poc",
196 "uuid":"6308d26e-fe1e-4268-bb28-20db2cd06914",
197 "interruptable":false,
198 "description":"This is a poc task",
199 "startTime":"2024-06-06T12:00:00Z",
200 "nextTime":"2024-06-06T12:00:00Z",
201 "state":"NORMAL",
202 "progress":"...",
203 "result":"...",
204 "message":"something is happening"
205 "jobData":{ "type":"poc", "description":"This is a poc task" }
206 }
207 """)))
208   public ResponseEntity<Object> details(@PathVariable TaskType type, @PathVariable UUID uuid)
209       throws ResponseStatusException {
210     logger.debug("Getting task details for {}:{}", type, uuid);
211 
212     try {
213       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
214       if (null == jobKey) {
215         return handleTaskNotFound();
216       }
217 
218       JobDetail jobDetail = scheduler.getJobDetail(jobKey);
219       JobDataMap jobDataMap = jobDetail.getJobDataMap();
220 
221       /* there should be only one */
222       Trigger trigger = scheduler.getTriggersOfJob(jobDetail.getKey()).get(0);
223       CronTrigger cron = ((CronTrigger) trigger);
224 
225       final Object[] result = new Object[1];
226       scheduler.getCurrentlyExecutingJobs().stream()
227           .filter(Objects::nonNull)
228           .filter(jobExecutionContext ->
229               jobExecutionContext.getJobDetail().getKey().equals(jobKey))
230           .forEach(jobExecutionContext -> {
231             logger.debug(
232                 "currently executing job {} with trigger {}.",
233                 jobExecutionContext.getJobDetail().getKey(),
234                 jobExecutionContext.getTrigger().getKey());
235 
236             result[0] = jobExecutionContext.getResult();
237           });
238 
239       return ResponseEntity.ok(new ObjectMapper()
240           .createObjectNode()
241           // immutable uuid, type and description
242           .put(Task.TYPE_KEY, jobDetail.getKey().getGroup())
243           .put(Task.UUID_KEY, jobDetail.getKey().getName())
244           .put(Task.INTERRUPTABLE_KEY, InterruptableJob.class.isAssignableFrom(jobDetail.getJobClass()))
245           .put(Task.DESCRIPTION_KEY, jobDataMap.getString(Task.DESCRIPTION_KEY))
246           .put(Task.CRON_EXPRESSION_KEY, cron.getCronExpression())
247           // TODO / XXX we could add a human-readable description of the cron expression using
248           // eg.
249           //   com.cronutils:cron-utils like:
250           //     CronParser cronParser = new
251           //         CronParser(CronDefinitionBuilder.instanceDefinitionFor(QUARTZ));
252           //     CronDescriptor.instance(locale).describe(cronParser.parse(cronExpression));
253           //   this could also be done front-end using eg.
254           // https://www.npmjs.com/package/cronstrue
255           //   which has the advantage of knowing the required locale for the human
256           // .put("cronDescription", cron.getCronExpression())
257           .put("timezone", cron.getTimeZone().getID())
258           .putPOJO("startTime", trigger.getStartTime())
259           .putPOJO("lastTime", trigger.getPreviousFireTime())
260           .putPOJO("nextFireTimes", TriggerUtils.computeFireTimes((OperableTrigger) cron, null, 5))
261           .putPOJO(Task.STATE_KEY, scheduler.getTriggerState(trigger.getKey()))
262           .putPOJO("progress", result[0])
263           .put(Task.LAST_RESULT_KEY, jobDataMap.getString(Task.LAST_RESULT_KEY))
264           .putPOJO("jobData", jobDataMap));
265     } catch (SchedulerException e) {
266       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
267     }
268   }
269 
270   @Operation(summary = "Start a task", description = "This will start the task if it is not already running")
271   @PutMapping(
272       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/start",
273       produces = MediaType.APPLICATION_JSON_VALUE)
274   @ApiResponse(
275       responseCode = "404",
276       description = "Task not found",
277       content =
278           @Content(
279               mediaType = MediaType.APPLICATION_JSON_VALUE,
280               schema = @Schema(example = "{\"message\":\"Task not found\",\"code\":404}")))
281   @ApiResponse(
282       responseCode = "202",
283       description = "Task is started",
284       content =
285           @Content(
286               mediaType = MediaType.APPLICATION_JSON_VALUE,
287               schema = @Schema(example = "{\"message\":\"Task starting accepted\",\"code\":202}")))
288   public ResponseEntity<Object> startTask(@PathVariable TaskType type, @PathVariable UUID uuid)
289       throws ResponseStatusException {
290     logger.debug("Starting task {}:{}", type, uuid);
291 
292     try {
293       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
294       if (null == jobKey) {
295         return handleTaskNotFound();
296       }
297       scheduler.triggerJob(jobKey);
298       return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
299           .body(new ObjectMapper().createObjectNode().put("message", "Task starting accepted"));
300 
301     } catch (SchedulerException e) {
302       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
303     }
304   }
305 
306   @Operation(
307       summary = "Stop a task irrevocably",
308       description =
309           """
310 This will stop a running task, if the task is not running, nothing will happen.
311 This can leave the application in an inconsistent state.
312 A task that is not interruptable cannot be stopped.
313 A stopped task cannot be restarted, it fire again depending on the schedule.
314 """)
315   @PutMapping(
316       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/stop",
317       produces = MediaType.APPLICATION_JSON_VALUE)
318   @ApiResponse(
319       responseCode = "404",
320       description = "Task not found",
321       content =
322           @Content(
323               mediaType = MediaType.APPLICATION_JSON_VALUE,
324               schema = @Schema(example = "{\"message\":\"Task not found\", \"code\":404}")))
325   @ApiResponse(
326       responseCode = "202",
327       description = "Task is stopping",
328       content =
329           @Content(
330               mediaType = MediaType.APPLICATION_JSON_VALUE,
331               schema = @Schema(example = """
332 {
333 "message":"Task stopping accepted".
334 "succes":true
335 }
336 """)))
337   @ApiResponse(
338       responseCode = "400",
339       description = "The task cannot be stopped as it does not implement the InterruptableJob interface.",
340       content =
341           @Content(
342               mediaType = MediaType.APPLICATION_JSON_VALUE,
343               schema = @Schema(example = """
344 { "message":"Task cannot be stopped" }
345 """)))
346   public ResponseEntity<Object> stopTask(@PathVariable TaskType type, @PathVariable UUID uuid)
347       throws ResponseStatusException {
348     logger.debug("Stopping task {}:{}", type, uuid);
349 
350     try {
351       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
352       if (null == jobKey) {
353         return handleTaskNotFound();
354       }
355 
356       if (InterruptableJob.class.isAssignableFrom(
357           scheduler.getJobDetail(jobKey).getJobClass())) {
358         boolean interrupted = scheduler.interrupt(jobKey);
359         return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
360             .body(new ObjectMapper()
361                 .createObjectNode()
362                 .put("message", "Task stopping accepted")
363                 .put("succes", interrupted));
364       } else {
365         return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_BAD_REQUEST))
366             .body(new ObjectMapper()
367                 .createObjectNode()
368                 .put("message", "Task cannot be stopped")
369                 .put("succes", false));
370       }
371 
372     } catch (SchedulerException e) {
373       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
374     }
375   }
376 
377   @Operation(
378       summary = "Delete a task",
379       description = "This will remove the task from the scheduler and delete all information about the task")
380   @DeleteMapping(
381       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
382       produces = MediaType.APPLICATION_JSON_VALUE)
383   @ApiResponse(
384       responseCode = "404",
385       description = "Task not found",
386       content =
387           @Content(
388               mediaType = MediaType.APPLICATION_JSON_VALUE,
389               schema = @Schema(example = "{\"message\":\"Task not found\"}")))
390   @ApiResponse(responseCode = "204", description = "Task is deleted")
391   public ResponseEntity<Object> delete(@PathVariable TaskType type, @PathVariable UUID uuid)
392       throws ResponseStatusException {
393 
394     try {
395       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
396       if (null == jobKey) {
397         return handleTaskNotFound();
398       }
399 
400       boolean succes = scheduler.deleteJob(jobKey);
401       logger.info("Task {}:{} deletion {}", type, uuid, (succes ? "succeeded" : "failed"));
402       // cleanup the schedule from the business objects
403       switch (type) {
404         case INDEX:
405           deleteScheduleFromSearchIndex(uuid);
406           break;
407         case FAILINGPOC:
408         case POC:
409         case INTERRUPTABLEPOC:
410           // no action required, as these are not managed in Tailormap
411         default:
412           break;
413       }
414       return ResponseEntity.noContent().build();
415     } catch (SchedulerException e) {
416       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
417     }
418   }
419 
420   private void deleteScheduleFromSearchIndex(UUID uuid) {
421     searchIndexRepository.findByTaskScheduleUuid(uuid).forEach(searchIndex -> {
422       searchIndex.setSchedule(null);
423       searchIndexRepository.save(searchIndex);
424     });
425   }
426 
427   private ResponseEntity<Object> handleTaskNotFound() {
428     return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_NOT_FOUND))
429         .contentType(MediaType.APPLICATION_JSON)
430         .body(new ObjectMapper()
431             .createObjectNode()
432             .put("message", "Task not found")
433             .put("code", HTTP_NOT_FOUND));
434   }
435 }