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