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
56   * tasks belongs in the domain of the specific controller or Spring Data REST API as that requires
57   * specific configuration information.
58   */
59  @RestController
60  public class TaskAdminController {
61    private static final Logger logger =
62        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
63  
64    private final Scheduler scheduler;
65    private final TaskManagerService taskManagerService;
66    private final SearchIndexRepository searchIndexRepository;
67  
68    public TaskAdminController(
69        @Autowired Scheduler scheduler,
70        @Autowired TaskManagerService taskManagerService,
71        @Autowired SearchIndexRepository searchIndexRepository) {
72      this.scheduler = scheduler;
73      this.taskManagerService = taskManagerService;
74      this.searchIndexRepository = searchIndexRepository;
75    }
76  
77    @Operation(
78        summary = "List all tasks, optionally filtered by type",
79        description =
80            """
81            This will return a list of all tasks, optionally filtered by task type.
82            The state of the task is one of the Quartz Trigger states.
83            The state can be one of: NONE, NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED or null in error conditions.
84            """)
85    @GetMapping(
86        path = "${tailormap-api.admin.base-path}/tasks",
87        produces = MediaType.APPLICATION_JSON_VALUE)
88    @ApiResponse(
89        responseCode = "200",
90        description = "List of all tasks, this list may be empty",
91        content =
92            @Content(
93                mediaType = MediaType.APPLICATION_JSON_VALUE,
94                schema =
95                    @Schema(
96                        example =
97                            """
98                            {"tasks":[
99                            {"uuid":"6308d26e-fe1e-4268-bb28-20db2cd06914","type":"index", "state":"NORMAL", "interruptable": false},
100                           {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca8","type":"poc", "state": "BLOCKED", "interruptable": false},
101                           {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca9","type":"poc", "state": "PAUSED", "interruptable": false},
102                           {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca2","type":"poc", "state": "COMPLETE", "interruptable": false},
103                           {"uuid":"d5ce9152-e90e-4b5a-b129-3b2366cabca3","type":"interuptablepoc", "state": "ERROR", "interruptable": true}
104                           ]}
105                           """)))
106   public ResponseEntity<Object> list(@RequestParam(required = false) String type)
107       throws ResponseStatusException {
108     logger.debug("Listing all tasks (optional type filter: {})", (null == type ? "all" : type));
109     final List<ObjectNode> tasks = new ArrayList<>();
110 
111     final GroupMatcher<JobKey> groupMatcher =
112         (null == type ? GroupMatcher.anyGroup() : GroupMatcher.groupEquals(type));
113     try {
114       scheduler.getJobKeys(groupMatcher).stream()
115           .map(
116               jobKey -> {
117                 try {
118                   return scheduler.getJobDetail(jobKey);
119                 } catch (SchedulerException e) {
120                   logger.error("Error getting task detail", e);
121                   return null;
122                 }
123               })
124           .filter(Objects::nonNull)
125           .forEach(
126               jobDetail -> {
127                 Trigger.TriggerState state;
128                 try {
129                   state =
130                       scheduler.getTriggerState(
131                           TriggerKey.triggerKey(
132                               jobDetail.getKey().getName(), jobDetail.getKey().getGroup()));
133                 } catch (SchedulerException e) {
134                   logger.error("Error getting task state", e);
135                   // ignore; to get a null (unknown) state
136                   state = null;
137                 }
138                 tasks.add(
139                     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(
159         new ObjectMapper()
160             .createObjectNode()
161             .set("tasks", new ObjectMapper().createArrayNode().addAll(tasks)));
162   }
163 
164   @Operation(
165       summary = "List all details for a given task",
166       description =
167           """
168           This will return the details of the task, including the status, progress,
169           result and any other information.
170           """)
171   @GetMapping(
172       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
173       produces = MediaType.APPLICATION_JSON_VALUE)
174   @ApiResponse(
175       responseCode = "404",
176       description = "Task not found",
177       content =
178           @Content(
179               mediaType = MediaType.APPLICATION_JSON_VALUE,
180               schema = @Schema(example = "{\"message\":\"Task not found\",\"code\":404}")))
181   @ApiResponse(
182       responseCode = "200",
183       description = "Details of the task",
184       content =
185           @Content(
186               mediaType = MediaType.APPLICATION_JSON_VALUE,
187               schema =
188                   @Schema(
189                       example =
190                           """
191                           {
192                             "type":"poc",
193                             "uuid":"6308d26e-fe1e-4268-bb28-20db2cd06914",
194                             "interruptable":false,
195                             "description":"This is a poc task",
196                             "startTime":"2024-06-06T12:00:00Z",
197                             "nextTime":"2024-06-06T12:00:00Z",
198                             "jobData":{
199                               "type":"poc",
200                               "description":"This is a poc task"
201                             },
202                             "state":"NORMAL",
203                             "progress":"...",
204                             "result":"...",
205                             "message":"something is happening"
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 -> jobExecutionContext.getJobDetail().getKey().equals(jobKey))
229           .forEach(
230               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(
240           new ObjectMapper()
241               .createObjectNode()
242               // immutable uuid, type and description
243               .put(Task.TYPE_KEY, jobDetail.getKey().getGroup())
244               .put(Task.UUID_KEY, jobDetail.getKey().getName())
245               .put(
246                   Task.INTERRUPTABLE_KEY,
247                   InterruptableJob.class.isAssignableFrom(jobDetail.getJobClass()))
248               .put(Task.DESCRIPTION_KEY, jobDataMap.getString(Task.DESCRIPTION_KEY))
249               .put(Task.CRON_EXPRESSION_KEY, cron.getCronExpression())
250               // TODO / XXX we could add a human-readable description of the cron expression using
251               // eg.
252               //   com.cronutils:cron-utils like:
253               //     CronParser cronParser = new
254               //         CronParser(CronDefinitionBuilder.instanceDefinitionFor(QUARTZ));
255               //     CronDescriptor.instance(locale).describe(cronParser.parse(cronExpression));
256               //   this could also be done front-end using eg.
257               // https://www.npmjs.com/package/cronstrue
258               //   which has the advantage of knowing the required locale for the human
259               // .put("cronDescription", cron.getCronExpression())
260               .put("timezone", cron.getTimeZone().getID())
261               .putPOJO("startTime", trigger.getStartTime())
262               .putPOJO("lastTime", trigger.getPreviousFireTime())
263               .putPOJO(
264                   "nextFireTimes", TriggerUtils.computeFireTimes((OperableTrigger) cron, null, 5))
265               .putPOJO(Task.STATE_KEY, scheduler.getTriggerState(trigger.getKey()))
266               .putPOJO("progress", result[0])
267               .put(Task.LAST_RESULT_KEY, jobDataMap.getString(Task.LAST_RESULT_KEY))
268               .putPOJO("jobData", jobDataMap));
269     } catch (SchedulerException e) {
270       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
271     }
272   }
273 
274   @Operation(
275       summary = "Start a task",
276       description = "This will start the task if it is not already running")
277   @PutMapping(
278       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/start",
279       produces = MediaType.APPLICATION_JSON_VALUE)
280   @ApiResponse(
281       responseCode = "404",
282       description = "Task not found",
283       content =
284           @Content(
285               mediaType = MediaType.APPLICATION_JSON_VALUE,
286               schema = @Schema(example = "{\"message\":\"Task not found\",\"code\":404}")))
287   @ApiResponse(
288       responseCode = "202",
289       description = "Task is started",
290       content =
291           @Content(
292               mediaType = MediaType.APPLICATION_JSON_VALUE,
293               schema = @Schema(example = "{\"message\":\"Task starting accepted\",\"code\":202}")))
294   public ResponseEntity<Object> startTask(@PathVariable TaskType type, @PathVariable UUID uuid)
295       throws ResponseStatusException {
296     logger.debug("Starting task {}:{}", type, uuid);
297 
298     try {
299       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
300       if (null == jobKey) {
301         return handleTaskNotFound();
302       }
303       scheduler.triggerJob(jobKey);
304       return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
305           .body(new ObjectMapper().createObjectNode().put("message", "Task starting accepted"));
306 
307     } catch (SchedulerException e) {
308       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
309     }
310   }
311 
312   @Operation(
313       summary = "Stop a task irrevocably",
314       description =
315           """
316               This will stop a running task, if the task is not running, nothing will happen.
317               This can leave the application in an inconsistent state.
318               A task that is not interruptable cannot be stopped.
319               A stopped task cannot be restarted, it fire again depending on the schedule.
320               """)
321   @PutMapping(
322       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/stop",
323       produces = MediaType.APPLICATION_JSON_VALUE)
324   @ApiResponse(
325       responseCode = "404",
326       description = "Task not found",
327       content =
328           @Content(
329               mediaType = MediaType.APPLICATION_JSON_VALUE,
330               schema = @Schema(example = "{\"message\":\"Task not found\", \"code\":404}")))
331   @ApiResponse(
332       responseCode = "202",
333       description = "Task is stopping",
334       content =
335           @Content(
336               mediaType = MediaType.APPLICATION_JSON_VALUE,
337               schema =
338                   @Schema(
339                       example =
340                           """
341                                 {
342                                 "message":"Task stopping accepted".
343                                 "succes":true
344                                 }
345                               """)))
346   @ApiResponse(
347       responseCode = "400",
348       description =
349           "The task cannot be stopped as it does not implement the InterruptableJob interface.",
350       content =
351           @Content(
352               mediaType = MediaType.APPLICATION_JSON_VALUE,
353               schema =
354                   @Schema(
355                       example =
356                           """
357                                         {
358                                         "message":"Task cannot be stopped"
359                                         }
360                                       """)))
361   public ResponseEntity<Object> stopTask(@PathVariable TaskType type, @PathVariable UUID uuid)
362       throws ResponseStatusException {
363     logger.debug("Stopping task {}:{}", type, uuid);
364 
365     try {
366       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
367       if (null == jobKey) {
368         return handleTaskNotFound();
369       }
370 
371       if (InterruptableJob.class.isAssignableFrom(scheduler.getJobDetail(jobKey).getJobClass())) {
372         boolean interrupted = scheduler.interrupt(jobKey);
373         return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
374             .body(
375                 new ObjectMapper()
376                     .createObjectNode()
377                     .put("message", "Task stopping accepted")
378                     .put("succes", interrupted));
379       } else {
380         return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_BAD_REQUEST))
381             .body(
382                 new ObjectMapper()
383                     .createObjectNode()
384                     .put("message", "Task cannot be stopped")
385                     .put("succes", false));
386       }
387 
388     } catch (SchedulerException e) {
389       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
390     }
391   }
392 
393   @Operation(
394       summary = "Delete a task",
395       description =
396           "This will remove the task from the scheduler and delete all information about the task")
397   @DeleteMapping(
398       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
399       produces = MediaType.APPLICATION_JSON_VALUE)
400   @ApiResponse(
401       responseCode = "404",
402       description = "Task not found",
403       content =
404           @Content(
405               mediaType = MediaType.APPLICATION_JSON_VALUE,
406               schema = @Schema(example = "{\"message\":\"Task not found\"}")))
407   @ApiResponse(responseCode = "204", description = "Task is deleted")
408   public ResponseEntity<Object> delete(@PathVariable TaskType type, @PathVariable UUID uuid)
409       throws ResponseStatusException {
410 
411     try {
412       JobKey jobKey = taskManagerService.getJobKey(type, uuid);
413       if (null == jobKey) {
414         return handleTaskNotFound();
415       }
416 
417       boolean succes = scheduler.deleteJob(jobKey);
418       logger.info("Task {}:{} deletion {}", type, uuid, (succes ? "succeeded" : "failed"));
419       // cleanup the schedule from the business objects
420       switch (type) {
421         case INDEX:
422           deleteScheduleFromSearchIndex(uuid);
423           break;
424         case FAILINGPOC:
425         case POC:
426         case INTERRUPTABLEPOC:
427         // no action required, as these are not managed in Tailormap
428         default:
429           break;
430       }
431       return ResponseEntity.noContent().build();
432     } catch (SchedulerException e) {
433       throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error getting task", e);
434     }
435   }
436 
437   private void deleteScheduleFromSearchIndex(UUID uuid) {
438     searchIndexRepository
439         .findByTaskScheduleUuid(uuid)
440         .forEach(
441             searchIndex -> {
442               searchIndex.setSchedule(null);
443               searchIndexRepository.save(searchIndex);
444             });
445   }
446 
447   private ResponseEntity<Object> handleTaskNotFound() {
448     return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_NOT_FOUND))
449         .contentType(MediaType.APPLICATION_JSON)
450         .body(
451             new ObjectMapper()
452                 .createObjectNode()
453                 .put("message", "Task not found")
454                 .put("code", HTTP_NOT_FOUND));
455   }
456 }