1
2
3
4
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
56
57
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
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
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
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
251
252
253
254
255
256
257
258
259
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
420 switch (type) {
421 case INDEX:
422 deleteScheduleFromSearchIndex(uuid);
423 break;
424 case FAILINGPOC:
425 case POC:
426 case INTERRUPTABLEPOC:
427
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 }