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
60
61
62
63
64
65
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
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
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
248
249
250
251
252
253
254
255
256
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
403 switch (type) {
404 case INDEX:
405 deleteScheduleFromSearchIndex(uuid);
406 break;
407 case FAILINGPOC:
408 case POC:
409 case INTERRUPTABLEPOC:
410
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 }