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 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
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 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
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
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
232
233
234
235
236
237
238
239
240
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
378 switch (type) {
379 case INDEX:
380 deleteScheduleFromSearchIndex(uuid);
381 break;
382 case PROMETHEUS_PING:
383
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 }