diff --git a/CHANGELOG.md b/CHANGELOG.md index 153e6ef0c..60ca2009a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,43 +1,48 @@ # Changelog +## v1.5.0 + +- Adjusted transaction handling for trigger life cycle events +- Base event entry is only written for done/finished trigger +- Base statistics added for a task + ## v1.4.6 - (2025-01-08) -- Trigger history with more details - not waiting for the transaction +- Trigger history with more details - not waiting for the transaction ## v1.4.5 - (2025-01-08) -- Adjusted path matching to support sub routes for an SPA web app +- Adjusted path matching to support sub routes for an SPA web app ## v1.4.4 - (2025-01-08) -- Fixed UI routing -- added support for thymeleaf - adding index.html to template folder +- Fixed UI routing +- added support for thymeleaf - adding index.html to template folder ## v1.4.3 - (2025-01-08) -- Scheduler service leaves current transaction before executing task +- Scheduler service leaves current transaction before executing task ## v1.4.2 - (2025-01-06) -- Fixed count by TaskId -- added search by ID to the UI -- added search by task to history +- Fixed count by TaskId +- added search by ID to the UI +- added search by task to history ## v1.4.1 - (2025-01-06) -- Added state to the TriggerLifeCycleEvent -- @Transactional annotation is taken from the method first +- Added state to the TriggerLifeCycleEvent +- @Transactional annotation is taken from the method first ## v1.4.0 - (2025-01-05) -- @Transactional Annotation support -- PersistentTask instead of Task or SpringBeanTask - +- @Transactional Annotation support +- PersistentTask instead of Task or SpringBeanTask ## v1.3.1 - (2025-01-02) -- Bugfixes -- Sprign Transaction Template support +- Bugfixes +- Sprign Transaction Template support ## v1.3.0 - (2025-01-01) diff --git a/RUN_AND_BUILD.md b/RUN_AND_BUILD.md index 3d589d795..3a188efa8 100644 --- a/RUN_AND_BUILD.md +++ b/RUN_AND_BUILD.md @@ -1,16 +1,20 @@ mvn versions:display-dependency-updates -mvn versions:set -DnewVersion=1.4.6 -DgenerateBackupPoms=false -git tag -a v1.4.6 -m "v1.4.6 release" -mvn versions:set -DnewVersion=1.4.7-SNAPSHOT -DgenerateBackupPoms=false +mvn versions:set -DnewVersion=1.5.0 -DgenerateBackupPoms=false +git tag -a v1.5.0 -m "v1.5.0 release" +mvn versions:set -DnewVersion=1.5.1-SNAPSHOT -DgenerateBackupPoms=false ## postgres + docker run --name pg-container -e POSTGRES_USER=sa -e POSTGRES_PASSWORD=veryStrong123 -p 5432:5432 -d postgres ## azure-sql-edge + docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=veryStrong123' -p 1433:1433 --name azuresqledge -d mcr.microsoft.com/azure-sql-edge ## MariaDB + docker run -e MYSQL_ROOT_PASSWORD=veryStrong123 -e MYSQL_DATABASE=testdb -e MYSQL_USER=sa -e MYSQL_PASSWORD=veryStrong123 -p 3306:3306 -d mariadb:latest ## MySQL + docker run -e MYSQL_ROOT_PASSWORD=veryStrong123 -e MYSQL_DATABASE=testdb -e MYSQL_USER=sa -e MYSQL_PASSWORD=veryStrong123 -p 3306:3306 -d mysql diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/PersistentTaskService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/PersistentTaskService.java index 921ad22b4..c43a9d7f1 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/PersistentTaskService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/PersistentTaskService.java @@ -9,6 +9,7 @@ import java.util.concurrent.Future; import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Pageable; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -55,17 +56,23 @@ public Optional getLastTriggerData(TriggerKey key) { } } + public Optional getLastDetailData(TriggerKey key) { + var data = historyService.findAllDetailsForKey(key, Pageable.ofSize(1)); + if (data.isEmpty()) return Optional.empty(); + return Optional.of(data.getContent().get(0).getData()); + } + @EventListener void queue(TriggerTaskCommand event) { if (event.triggers().size() == 1) { runOrQueue(event.triggers().iterator().next()); } else { - queueAll(event.triggers()); + queue(event.triggers()); } } /** - * Queues the given triggers. + * Queues/updates the given triggers, if the {@link TriggerKey} is already present * * @param the state type * @param triggers the triggers to add @@ -73,12 +80,24 @@ void queue(TriggerTaskCommand event) { */ @Transactional(timeout = 10) @NonNull - public List queueAll(Collection> triggers) { + public List queue(Collection> triggers) { return triggers.stream() // .map(t -> triggerService.queue(t)) // .map(TriggerEntity::getKey) // .toList(); } + /** + * Queues/updates the given trigger, if the {@link TriggerKey} is already present. + * + * @param the state type + * @param trigger the trigger to add + * @return the {@link TriggerKey} + */ + @Transactional(timeout = 5) + @NonNull + public TriggerKey queue(AddTriggerRequest trigger) { + return triggerService.queue(trigger).getKey(); + } /** * Runs the given trigger if a free threads are available diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/SpringPersistentTasksConfig.java b/core/src/main/java/org/sterl/spring/persistent_tasks/SpringPersistentTasksConfig.java index a2f7a70b8..39fcfb2c1 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/SpringPersistentTasksConfig.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/SpringPersistentTasksConfig.java @@ -3,10 +3,12 @@ import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @Configuration @EnableScheduling +@EnableAsync @AutoConfigurationPackage(basePackageClasses = EnableSpringPersistentTasks.class) @ComponentScan(basePackageClasses = EnableSpringPersistentTasks.class) public class SpringPersistentTasksConfig { diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/api/HistoryOverview.java b/core/src/main/java/org/sterl/spring/persistent_tasks/api/HistoryOverview.java deleted file mode 100644 index 76cecc927..000000000 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/api/HistoryOverview.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.sterl.spring.persistent_tasks.api; - -import java.time.OffsetDateTime; - -public record HistoryOverview( - long instanceId, - String taskName, - long entryCount, - OffsetDateTime start, - OffsetDateTime end, - OffsetDateTime createdTime, - long executionCount, - double runningDurationInMs - ) { -} diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/api/TaskStatusHistoryOverview.java b/core/src/main/java/org/sterl/spring/persistent_tasks/api/TaskStatusHistoryOverview.java new file mode 100644 index 000000000..5619d4a50 --- /dev/null +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/api/TaskStatusHistoryOverview.java @@ -0,0 +1,16 @@ +package org.sterl.spring.persistent_tasks.api; + +import java.time.OffsetDateTime; + +public record TaskStatusHistoryOverview( + String taskName, + TriggerStatus status, + Long executionCount, + OffsetDateTime firstRun, + OffsetDateTime lastRun, + Number maxDurationMs, + Number minDurationMs, + Number avgDurationMs, + Number avgExecutionCount + ) { +} \ No newline at end of file diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/api/Trigger.java b/core/src/main/java/org/sterl/spring/persistent_tasks/api/Trigger.java index 689ea5be7..2e1120b4d 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/api/Trigger.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/api/Trigger.java @@ -2,8 +2,6 @@ import java.time.OffsetDateTime; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; - import lombok.Data; @Data diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerStatus.java b/core/src/main/java/org/sterl/spring/persistent_tasks/api/TriggerStatus.java similarity index 85% rename from core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerStatus.java rename to core/src/main/java/org/sterl/spring/persistent_tasks/api/TriggerStatus.java index 5c4bcd0ed..5f383df03 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerStatus.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/api/TriggerStatus.java @@ -1,4 +1,4 @@ -package org.sterl.spring.persistent_tasks.shared.model; +package org.sterl.spring.persistent_tasks.api; import java.util.EnumSet; import java.util.Set; diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java index 52e1aafa9..92012085a 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java @@ -10,12 +10,13 @@ import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.lang.Nullable; +import org.sterl.spring.persistent_tasks.api.TaskStatusHistoryOverview; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryDetailEntity; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryLastStateEntity; import org.sterl.spring.persistent_tasks.history.repository.TriggerHistoryDetailRepository; import org.sterl.spring.persistent_tasks.history.repository.TriggerHistoryLastStateRepository; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.stereotype.TransactionalService; import org.sterl.spring.persistent_tasks.trigger.TriggerService; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; @@ -64,7 +65,7 @@ public Page findAllDetailsForKey(TriggerKey key) { return findAllDetailsForKey(key, PageRequest.of(0, 100)); } public Page findAllDetailsForKey(TriggerKey key, Pageable page) { - page = sortByIdIfNeeded(page); + page = applyDefaultSortIfNeeded(page); return triggerHistoryDetailRepository.listKnownStatusFor(key, page); } @@ -97,7 +98,7 @@ public long countTriggers(TriggerKey key) { public Page findTriggerState( @Nullable TriggerKey key, Pageable page) { - page = sortByIdIfNeeded(page); + page = applyDefaultSortIfNeeded(page); if (key == null) return triggerHistoryLastStateRepository.findAll(page); if (key.getId() == null && key.getTaskName() == null) return triggerHistoryLastStateRepository.findAll(page); if (key.getId() == null && key.getTaskName() != null) { @@ -109,11 +110,15 @@ public Page findTriggerState( page); } - private Pageable sortByIdIfNeeded(Pageable page) { + private Pageable applyDefaultSortIfNeeded(Pageable page) { if (page.getSort() == Sort.unsorted()) { return PageRequest.of(page.getPageNumber(), page.getPageSize(), - Sort.by(Direction.DESC, "id")); + Sort.by(Direction.DESC, "data.createdTime", "id")); } return page; } + + public List taskStatusHistory() { + return triggerHistoryLastStateRepository.listTriggerStatus(); + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java index 662d4bef9..eb2ec1f18 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java @@ -4,7 +4,6 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort.Direction; import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PagedModel; import org.springframework.web.bind.annotation.GetMapping; @@ -12,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.sterl.spring.persistent_tasks.api.TaskStatusHistoryOverview; import org.sterl.spring.persistent_tasks.api.Trigger; import org.sterl.spring.persistent_tasks.api.TriggerKey; import org.sterl.spring.persistent_tasks.history.HistoryService; @@ -32,12 +32,16 @@ public List listInstances(@PathVariable("instanceId") long instanceId) return FromTriggerStateDetailEntity.INSTANCE.convert( // historyService.findAllDetailsForInstance(instanceId)); } + @GetMapping("task-status-history") + public List taskStatusHistory() { + return historyService.taskStatusHistory(); + } @GetMapping("history") public PagedModel list( @RequestParam(name = "id", required = false) String id, @RequestParam(name = "taskName", required = false) String taskName, - @PageableDefault(size = 100, direction = Direction.DESC, sort = "id") Pageable pageable) { + @PageableDefault(size = 100) Pageable pageable) { return FromLastTriggerStateEntity.INSTANCE.toPage( // historyService.findTriggerState( diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/component/TriggerHistoryComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/component/TriggerHistoryComponent.java index c4ab6e68d..14e829ec3 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/component/TriggerHistoryComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/component/TriggerHistoryComponent.java @@ -3,14 +3,18 @@ import java.time.OffsetDateTime; import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryDetailEntity; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryLastStateEntity; import org.sterl.spring.persistent_tasks.history.repository.TriggerHistoryDetailRepository; import org.sterl.spring.persistent_tasks.history.repository.TriggerHistoryLastStateRepository; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; import org.sterl.spring.persistent_tasks.shared.stereotype.TransactionalCompontant; import org.sterl.spring.persistent_tasks.trigger.event.TriggerLifeCycleEvent; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.trigger.event.TriggerRunningEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,27 +27,46 @@ public class TriggerHistoryComponent { private final TriggerHistoryLastStateRepository triggerHistoryLastStateRepository; private final TriggerHistoryDetailRepository triggerHistoryDetailRepository; - public void write(TriggerEntity e) { - var state = new TriggerHistoryLastStateEntity(); - state.setId(e.getId()); - state.setData(e.getData().toBuilder().build()); - triggerHistoryLastStateRepository.save(state); + // we have to ensure to run in an own transaction + // as if the trigger fails, a rollback would also remove this entry + // furthermore async to ensure that we would not block + // as REQURES_NEW would block two DB connections ... + @Async + @Transactional(timeout = 10) + @EventListener + public void onRunning(TriggerRunningEvent e) { + log.debug("Received event={} for {} new status={}", + e.getClass().getSimpleName(), + e.key(), e.status()); + + execute(e.id(), e.data(), false); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + void onPersistentTaskEvent(TriggerLifeCycleEvent e) { + if (e instanceof TriggerRunningEvent) return; // we have an own listener for that + log.debug("Received event={} for {} new status={}", + e.getClass().getSimpleName(), + e.key(), e.status()); + + + execute(e.id(), e.data(), e.isDone()); + } + + public void execute(final long triggerId, final TriggerData data, boolean isDone) { + if (isDone) { + final var state = new TriggerHistoryLastStateEntity(); + state.setId(triggerId); + state.setData(data.copy()); + triggerHistoryLastStateRepository.save(state); + } var detail = new TriggerHistoryDetailEntity(); - detail.setInstanceId(e.getId()); - detail.setData(e.getData().toBuilder() + detail.setInstanceId(triggerId); + detail.setData(data.toBuilder() .state(null) + .createdTime(OffsetDateTime.now()) .build()); - detail.getData().setCreatedTime(OffsetDateTime.now()); triggerHistoryDetailRepository.save(detail); } - - @Transactional(timeout = 10) - @EventListener - public void onPersistentTaskEvent(TriggerLifeCycleEvent triggerLifeCycleEvent) { - log.debug("Received event={} for {} new status={}", - triggerLifeCycleEvent.getClass().getSimpleName(), - triggerLifeCycleEvent.key(), triggerLifeCycleEvent.status()); - write(triggerLifeCycleEvent.trigger()); - } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepository.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepository.java index a01b72da4..0f2f42ab7 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepository.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepository.java @@ -2,34 +2,12 @@ import java.util.List; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.sterl.spring.persistent_tasks.api.HistoryOverview; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryDetailEntity; public interface TriggerHistoryDetailRepository extends HistoryTriggerRepository { - @Query(""" - SELECT new org.sterl.spring.persistent_tasks.api.HistoryOverview( - e.instanceId, - e.data.key.taskName, - count(1) as entryCount, - MIN(e.data.start) as start, - MAX(e.data.end) as end, - MIN(e.data.createdTime) as createdTime, - MAX(e.data.executionCount) as executionCount, - AVG(e.data.runningDurationInMs) as runningDurationInMs - ) - FROM #{#entityName} e - GROUP BY - e.instanceId, - e.data.key.taskName - ORDER BY end DESC, createdTime DESC - """) - Page listHistoryOverview(Pageable page); - @Query(""" SELECT e FROM #{#entityName} e diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepository.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepository.java index 42239566a..0f0afcf25 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepository.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepository.java @@ -1,8 +1,28 @@ package org.sterl.spring.persistent_tasks.history.repository; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.sterl.spring.persistent_tasks.api.TaskStatusHistoryOverview; import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryLastStateEntity; public interface TriggerHistoryLastStateRepository extends HistoryTriggerRepository { - + @Query(""" + SELECT new org.sterl.spring.persistent_tasks.api.TaskStatusHistoryOverview( + e.data.key.taskName, + e.data.status, + count(1), + MIN(e.data.runAt) as firstRun, + MAX(e.data.runAt) as lastRun, + MAX(e.data.runningDurationInMs) as maxDuration, + MIN(e.data.runningDurationInMs) as minDuration, + AVG(e.data.runningDurationInMs) as avgDuration, + AVG(e.data.executionCount) as avgExecutionCount + ) + FROM #{#entityName} e + GROUP BY e.data.key.taskName, e.data.status + ORDER BY e.data.key.taskName ASC, e.data.status ASC + """) + List listTriggerStatus(); } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerService.java index b72014aaa..54626da77 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerService.java @@ -4,7 +4,9 @@ import java.time.OffsetDateTime; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import org.springframework.lang.NonNull; @@ -28,11 +30,10 @@ import lombok.extern.slf4j.Slf4j; /** - * Use this service if direct access to the Scheduler is required. - *
- * Note: This Service is optional, as it could be disabled if no background - * tasks should be execute on this note. As so the {@link TriggerService} should be - * preferred to queue tasks. + * Use this service if direct access to the Scheduler is required.
+ * Note: This Service is optional, as it could be disabled if no + * background tasks should be execute on this note. As so the + * {@link TriggerService} should be preferred to queue tasks. */ @RequiredArgsConstructor @Slf4j @@ -44,6 +45,7 @@ public class SchedulerService { private final TaskExecutorComponent taskExecutor; private final EditSchedulerStatusComponent editSchedulerStatus; private final TransactionTemplate trx; + private final Map shouldRun = new ConcurrentHashMap<>(); @PostConstruct public void start() { @@ -70,22 +72,20 @@ public void shutdownNow() { editSchedulerStatus.offline(name); } + @Transactional public SchedulerEntity pingRegistry() { - // using trx template to ensure the TRX is started if we use this method internally - return trx.execute(t -> { - var result = editSchedulerStatus.checkinToRegistry(name); - result.setRunnungTasks(taskExecutor.getRunningTasks()); - result.setTasksSlotCount(taskExecutor.getMaxThreads()); - log.debug("Ping {}", result); - return result; - }); + var result = editSchedulerStatus.checkinToRegistry(name); + result.setRunnungTasks(taskExecutor.getRunningTasks()); + result.setTasksSlotCount(taskExecutor.getMaxThreads()); + log.debug("Ping {}", result); + return result; } - + public SchedulerEntity getScheduler() { var result = editSchedulerStatus.get(name); return result; } - + public Optional findStatus(String name) { return editSchedulerStatus.find(name); } @@ -99,8 +99,8 @@ public List> triggerNextTasks() { } /** - * Like {@link #triggerNextTasks()} but allows to set the time e.g. to the future to trigger - * tasks which wouldn't be triggered now. + * Like {@link #triggerNextTasks()} but allows to set the time e.g. to the + * future to trigger tasks which wouldn't be triggered now. *

* This method should not be called in a transaction! *

@@ -109,11 +109,10 @@ public List> triggerNextTasks() { public List> triggerNextTasks(OffsetDateTime timeDue) { if (taskExecutor.getFreeThreads() > 0) { final var result = trx.execute(t -> { - var triggers = triggerService.lockNextTrigger(name, - taskExecutor.getFreeThreads(), timeDue); - pingRegistry().addRunning(triggers.size()); - return triggers; - }); + var triggers = triggerService.lockNextTrigger(name, taskExecutor.getFreeThreads(), timeDue); + pingRegistry().addRunning(triggers.size()); + return triggers; + }); return taskExecutor.submit(result); } else { @@ -123,33 +122,36 @@ public List> triggerNextTasks(OffsetDateTime timeDue) { } /** - * Runs the given trigger if a free threads are available - * and the runAt time is not in the future. - * @return the reference to the {@link Future} with the key, if no threads are available it is resolved + * Runs the given trigger if a free threads are available and the runAt time is + * not in the future. + * + * @return the reference to the {@link Future} with the key, if no threads are + * available it is resolved */ @Transactional(timeout = 10) - public TriggerKey runOrQueue( - AddTriggerRequest triggerRequest) { + public TriggerKey runOrQueue(AddTriggerRequest triggerRequest) { var trigger = triggerService.queue(triggerRequest); if (!trigger.shouldRunInFuture()) { if (taskExecutor.getFreeThreads() > 0) { trigger = triggerService.markTriggersAsRunning(trigger, name); pingRegistry().addRunning(1); + shouldRun.put(trigger.getId(), trigger); } else { - log.debug("Currently not enough free thread available {} of {} in use. PersistentTask {} queued.", + log.debug("Currently not enough free thread available {} of {} in use. PersistentTask {} queued.", taskExecutor.getFreeThreads(), taskExecutor.getMaxThreads(), trigger.getKey()); } } // we will listen for the commit event to execute this trigger ... return trigger.getKey(); } - + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) void checkIfTrigerIsRunning(TriggerAddedEvent addedTrigger) { - if (addedTrigger.isRunningOn(name) && !taskExecutor.isRunning(addedTrigger.trigger())) { + final var toRun = shouldRun.remove(addedTrigger.id()); + if (toRun != null) { log.debug("New triger added for imidiate execution {}", addedTrigger.key()); - taskExecutor.submit(addedTrigger.trigger()); + taskExecutor.submit(toRun); } } @@ -161,14 +163,11 @@ public SchedulerEntity getStatus() { public List rescheduleAbandonedTasks(OffsetDateTime timeout) { var schedulers = editSchedulerStatus.findOnlineSchedulers(timeout); - final List runningKeys = this.taskExecutor - .getRunningTriggers().stream() - .map(TriggerEntity::getKey) + final List runningKeys = this.taskExecutor.getRunningTriggers().stream().map(TriggerEntity::getKey) .toList(); int running = triggerService.markTriggersAsRunning(runningKeys, name); - log.debug("({}) - {} trigger(s) are running on {} schedulers", - running, runningKeys, schedulers); + log.debug("({}) - {} trigger(s) are running on {} schedulers", running, runningKeys, schedulers); return triggerService.rescheduleAbandonedTasks(timeout); } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/HasTriggerData.java b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/HasTriggerData.java index bdddf98b5..ce21166a2 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/HasTriggerData.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/HasTriggerData.java @@ -4,10 +4,18 @@ import java.time.OffsetDateTime; import org.sterl.spring.persistent_tasks.api.TaskId; +import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; public interface HasTriggerData { TriggerData getData(); + default TriggerKey key() { + return getData().getKey(); + } + default TriggerStatus status() { + return getData().getStatus(); + } default boolean isRunning() { return getData().getStatus() == TriggerStatus.RUNNING; } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerData.java b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerData.java index 1d3bfc607..42d68871c 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerData.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/model/TriggerData.java @@ -4,6 +4,7 @@ import java.time.OffsetDateTime; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import jakarta.persistence.AttributeOverride; import jakarta.persistence.AttributeOverrides; @@ -86,4 +87,8 @@ public void updateRunningDuration() { private String exceptionName; @Lob private String lastException; + + public TriggerData copy() { + return this.toBuilder().build(); + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java index 9aab4124d..daf4d49c0 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java @@ -11,8 +11,8 @@ import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.query.Param; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.model.HasTriggerData; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; @NoRepositoryBean public interface TriggerDataRepository extends JpaRepository { diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/task/TaskService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/task/TaskService.java index 9591d6cca..a37f75a07 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/task/TaskService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/task/TaskService.java @@ -5,10 +5,8 @@ import java.util.Set; import java.util.function.Consumer; -import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.sterl.spring.persistent_tasks.api.PersistentTask; import org.sterl.spring.persistent_tasks.api.TaskId; @@ -18,14 +16,12 @@ import lombok.RequiredArgsConstructor; @Service -@DependsOnDatabaseInitialization @RequiredArgsConstructor public class TaskService { private final TaskTransactionComponent taskTransactionComponent; private final TaskRepository taskRepository; - @Transactional(readOnly = true) public Set> findAllTaskIds() { return this.taskRepository.all(); } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java index 2269b9ca5..0ddee5d26 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java @@ -14,7 +14,7 @@ import org.sterl.spring.persistent_tasks.api.AddTriggerRequest; import org.sterl.spring.persistent_tasks.api.TaskId; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.stereotype.TransactionalService; import org.sterl.spring.persistent_tasks.task.TaskService; import org.sterl.spring.persistent_tasks.trigger.component.EditTriggerComponent; diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java index e07681148..c6abdfd22 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java @@ -39,7 +39,7 @@ public long count() { public PagedModel list( @RequestParam(name = "id", required = false) String id, @RequestParam(name = "taskName", required = false) String taskName, - @PageableDefault(size = 100, direction = Direction.DESC, sort = "id") + @PageableDefault(size = 100, direction = Direction.ASC, sort = "data.runAt") Pageable pageable) { return FromTriggerEntity.INSTANCE.toPage( triggerService.findAllTriggers( diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/EditTriggerComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/EditTriggerComponent.java index fca62afc3..f03c9cbc5 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/EditTriggerComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/EditTriggerComponent.java @@ -6,14 +6,15 @@ import java.util.List; import java.util.Optional; +import org.slf4j.event.Level; import org.springframework.context.ApplicationEventPublisher; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.sterl.spring.persistent_tasks.api.AddTriggerRequest; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; import org.sterl.spring.persistent_tasks.trigger.event.TriggerAddedEvent; import org.sterl.spring.persistent_tasks.trigger.event.TriggerCanceledEvent; import org.sterl.spring.persistent_tasks.trigger.event.TriggerFailedEvent; @@ -35,45 +36,58 @@ public class EditTriggerComponent { private final TriggerRepository triggerRepository; public Optional completeTaskWithSuccess(TriggerKey key, Serializable state) { - return this.completeTaskWithStatus(key, state, null); + final Optional result = triggerRepository.findByKey(key); + + result.ifPresent(t -> { + t.complete(null); + publisher.publishEvent(new TriggerSuccessEvent( + t.getId(), t.copyData(), state)); + log.debug("Setting {} to status={}", key, t.getData().getStatus()); + triggerRepository.delete(t); + }); + return result; } /** * Sets success or error based on the fact if an exception is given or not. */ - public Optional completeTaskWithStatus(TriggerKey key, Serializable state, Exception e) { + public Optional failTrigger( + TriggerKey key, + Serializable state, + Exception e, + OffsetDateTime retryAt) { final Optional result = triggerRepository.findByKey(key); + result.ifPresent(t -> { + log.atLevel(retryAt == null ? Level.ERROR : Level.WARN) + .setCause(e) + .log("{} failed, retryAt={}", + key, retryAt == null ? "no" : retryAt); t.complete(e); + publisher.publishEvent(new TriggerFailedEvent(t.getId(), t.copyData(), state, e, retryAt)); - if (t.getData().getStatus() == TriggerStatus.SUCCESS) { - publisher.publishEvent(new TriggerSuccessEvent(t, state)); - log.debug("Setting {} to status={} {}", key, t.getData().getStatus(), - e == null ? "" : "error=" + e.getClass().getSimpleName()); + if (retryAt == null) { + triggerRepository.delete(t); } else { - publisher.publishEvent(new TriggerFailedEvent(t, state, e)); - log.info("Setting {} to status={} {}", key, t.getData().getStatus(), - e == null ? "" : "error=" + e.getClass().getSimpleName()); + t.runAt(retryAt); } - }); + if (result.isEmpty()) { + log.error("Trigger with key={} not found and may be at a wrong state!", + key, e); + } return result; } - public Optional retryTrigger(TriggerKey id, OffsetDateTime retryAt) { - return triggerRepository // - .findByKey(id) // - .map(t -> t.runAt(retryAt)); - } - public Optional cancelTask(TriggerKey id) { return triggerRepository // .findByKey(id) // .map(t -> { t.cancel(); - publisher.publishEvent(new TriggerCanceledEvent(t, + publisher.publishEvent(new TriggerCanceledEvent( + t.getId(), t.copyData(), stateSerializer.deserializeOrNull(t.getData().getState()))); triggerRepository.delete(t); return t; @@ -94,7 +108,8 @@ public TriggerEntity addTrigger(AddTriggerRequest ti result = triggerRepository.save(result); log.debug("Added trigger={}", result); } - publisher.publishEvent(new TriggerAddedEvent(result, tigger.state())); + publisher.publishEvent(new TriggerAddedEvent( + result.getId(), result.copyData(), tigger.state())); return result; } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/LockNextTriggerComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/LockNextTriggerComponent.java index 99e5fb1ae..d1c3a3d17 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/LockNextTriggerComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/LockNextTriggerComponent.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; import org.sterl.spring.persistent_tasks.trigger.repository.TriggerRepository; diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java index 413d1653b..9fbdff8f1 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java @@ -10,7 +10,7 @@ import org.springframework.lang.Nullable; import org.sterl.spring.persistent_tasks.api.TaskId; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.stereotype.TransactionalCompontant; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; import org.sterl.spring.persistent_tasks.trigger.repository.TriggerRepository; diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/RunTriggerComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/RunTriggerComponent.java index 917950ffa..a2d080966 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/RunTriggerComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/RunTriggerComponent.java @@ -7,6 +7,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.sterl.spring.persistent_tasks.api.PersistentTask; import org.sterl.spring.persistent_tasks.task.TaskService; @@ -29,6 +31,7 @@ public class RunTriggerComponent { /** * Will execute the given {@link TriggerEntity} and handle any errors etc. */ + @Transactional(propagation = Propagation.NEVER) public Optional execute(TriggerEntity trigger) { if (trigger == null) { return Optional.empty(); @@ -40,7 +43,7 @@ public Optional execute(TriggerEntity trigger) { try { return taskAndState.call(); } catch (Exception e) { - return handleTaskException(taskAndState, e); + return failTaskAndState(taskAndState, e); } } @@ -53,12 +56,35 @@ private TaskAndState getTastAndState(TriggerEntity trigger) { return new TaskAndState(task, trx, state, trigger); } catch (Exception e) { // this trigger is somehow crap, no retry and done. - handleTaskException(new TaskAndState(null, Optional.empty(), null, trigger), e); + failTaskAndState(new TaskAndState(null, Optional.empty(), null, trigger), e); return null; } } + + private Optional failTaskAndState(TaskAndState taskAndState, Exception e) { + + var trigger = taskAndState.trigger; + var task = taskAndState.persistentTask; + Optional result; + + if (task != null + && task.retryStrategy().shouldRetry(trigger.getData().getExecutionCount(), e)) { + + final OffsetDateTime retryAt = task.retryStrategy().retryAt(trigger.getData().getExecutionCount(), e); + + result = editTrigger.failTrigger(trigger.getKey(), taskAndState.state, e, retryAt); + + } else { + log.error("{} failed, no more retries! {}", trigger.getKey(), + e == null ? "No exception given." : e.getMessage(), e); + + result = editTrigger.failTrigger(trigger.getKey(), taskAndState.state, e, null); + } + return result; + } + @RequiredArgsConstructor - private class TaskAndState { + class TaskAndState { final PersistentTask persistentTask; final Optional trx; final Serializable state; @@ -73,7 +99,9 @@ Optional call() { } private Optional runTask() { - eventPublisher.publishEvent(new TriggerRunningEvent(trigger, state)); + if (!trigger.isRunning()) trigger.runOn(trigger.getRunningOn()); + eventPublisher.publishEvent(new TriggerRunningEvent( + trigger.getId(), trigger.copyData(), state, trigger.getRunningOn())); persistentTask.accept(state); @@ -83,37 +111,4 @@ private Optional runTask() { return result; } } - - private Optional handleTaskException(TaskAndState taskAndState, - @Nullable Exception e) { - - var trigger = taskAndState.trigger; - var task = taskAndState.persistentTask; - var result = editTrigger.completeTaskWithStatus(trigger.getKey(), taskAndState.state, e); - - if (task != null - && task.retryStrategy().shouldRetry(trigger.getData().getExecutionCount(), e)) { - - final OffsetDateTime retryAt = task.retryStrategy().retryAt(trigger.getData().getExecutionCount(), e); - - result = editTrigger.retryTrigger(trigger.getKey(), retryAt); - if (result.isPresent()) { - var data = result.get().getData(); - log.warn("{} failed, retry will be done at={} status={}!", - trigger.getKey(), - data.getRunAt(), - data.getStatus(), - e); - } else { - log.error("Trigger with key={} not found and may be at a wrong state!", - trigger.getKey(), e); - } - } else { - log.error("{} failed, no more retries! {}", trigger.getKey(), - e == null ? "No exception given." : e.getMessage(), e); - - editTrigger.deleteTrigger(trigger); - } - return result; - } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerAddedEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerAddedEvent.java index 5f8d1f1e8..2bcf9fc0b 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerAddedEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerAddedEvent.java @@ -2,8 +2,18 @@ import java.io.Serializable; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -public record TriggerAddedEvent(TriggerEntity trigger, Serializable state) implements TriggerLifeCycleEvent { +/** + * Fired if a new trigger is added. + *

+ * Inside a transaction, it is save to join or listen for the AFTER_COMMIT + *

+ */ +public record TriggerAddedEvent(long id, TriggerData data, Serializable state) implements TriggerLifeCycleEvent { + @Override + public boolean isDone() { + return false; + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerCanceledEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerCanceledEvent.java index 04c1f252d..cc0e048e5 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerCanceledEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerCanceledEvent.java @@ -2,7 +2,18 @@ import java.io.Serializable; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -public record TriggerCanceledEvent(TriggerEntity trigger, Serializable state) implements TriggerLifeCycleEvent { +/** + * Fired if a trigger could be canceled before it is running. + *

+ * Inside a transaction, it is save to join or listen for the AFTER_COMMIT + *

+ */ +public record TriggerCanceledEvent(long id, TriggerData data, Serializable state) implements TriggerLifeCycleEvent { + + @Override + public boolean isDone() { + return true; + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerFailedEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerFailedEvent.java index dd6a25fa5..758408d68 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerFailedEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerFailedEvent.java @@ -1,9 +1,21 @@ package org.sterl.spring.persistent_tasks.trigger.event; import java.io.Serializable; +import java.time.OffsetDateTime; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -public record TriggerFailedEvent(TriggerEntity trigger, Serializable state, Exception exception) implements TriggerLifeCycleEvent { +/** + *

+ * Inside a transaction, it is save to join or listen for the AFTER_COMMIT + *

+ */ +public record TriggerFailedEvent(long id, + TriggerData data, Serializable state, + Exception exception, OffsetDateTime retryAt) implements TriggerLifeCycleEvent { + @Override + public boolean isDone() { + return retryAt == null; + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerLifeCycleEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerLifeCycleEvent.java index 59c1dad89..c5c50dc05 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerLifeCycleEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerLifeCycleEvent.java @@ -4,25 +4,24 @@ import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; -import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.HasTriggerData; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; /** - * Tag any events which are fired in case something changes on a trigger + * Tag any events which are fired in case something changes on a trigger. + * The attached data is already a copy, any modification to this data will have no effect. */ -public interface TriggerLifeCycleEvent { - default TriggerKey key() { - return trigger().getKey(); - } - default TriggerStatus status() { - return trigger().getData().getStatus(); - } - default boolean isRunningOn(String name) { - return trigger().isRunning() && name != null && name.equals(trigger().getRunningOn()); +public interface TriggerLifeCycleEvent extends HasTriggerData { + default TriggerData getData() { + return data(); } + long id(); @NonNull - TriggerEntity trigger(); + TriggerData data(); @Nullable Serializable state(); + /** + * @return true if the trigger was completed, either with success, error or canceled. + */ + boolean isDone(); } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerRunningEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerRunningEvent.java index c4a7fe411..3b7de39f3 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerRunningEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerRunningEvent.java @@ -2,11 +2,22 @@ import java.io.Serializable; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; /** * Event fired before a trigger is executed + *

+ * This event is maybe not in a transaction and so a transactional event listener will not work. + *

*/ -public record TriggerRunningEvent(TriggerEntity trigger, Serializable state) implements TriggerLifeCycleEvent { +public record TriggerRunningEvent(long id, TriggerData data, Serializable state, String runningOn) implements TriggerLifeCycleEvent { + public boolean isRunningOn(String name) { + return isRunning() && name != null && name.equals(runningOn); + } + + @Override + public boolean isDone() { + return false; + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerSuccessEvent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerSuccessEvent.java index 51dc55e01..fedb18b45 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerSuccessEvent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/event/TriggerSuccessEvent.java @@ -2,8 +2,17 @@ import java.io.Serializable; -import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -public record TriggerSuccessEvent(TriggerEntity trigger, Serializable state) implements TriggerLifeCycleEvent { +/** + *

+ * Inside a transaction, it is save to join or listen for the AFTER_COMMIT + *

+ */ +public record TriggerSuccessEvent(long id, TriggerData data, Serializable state) implements TriggerLifeCycleEvent { + @Override + public boolean isDone() { + return true; + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java index d1de923e2..4abcfbc85 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java @@ -4,9 +4,9 @@ import org.apache.commons.lang3.exception.ExceptionUtils; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.model.HasTriggerData; import org.sterl.spring.persistent_tasks.shared.model.TriggerData; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; import jakarta.annotation.Nullable; import jakarta.persistence.Column; @@ -111,4 +111,9 @@ public TriggerEntity withState(byte[] state) { this.data.setState(state); return this; } + + public TriggerData copyData() { + if (data == null) return null; + return this.data.copy(); + } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/repository/TriggerRepository.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/repository/TriggerRepository.java index da2c900f3..6817d4d27 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/repository/TriggerRepository.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/repository/TriggerRepository.java @@ -14,7 +14,7 @@ import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.shared.repository.TriggerDataRepository; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/AbstractSpringTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/AbstractSpringTest.java index 87d174246..f70df765d 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/AbstractSpringTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/AbstractSpringTest.java @@ -20,12 +20,12 @@ import org.springframework.transaction.support.TransactionTemplate; import org.sterl.spring.persistent_tasks.api.PersistentTask; import org.sterl.spring.persistent_tasks.api.TaskId; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.api.event.TriggerTaskCommand; import org.sterl.spring.persistent_tasks.history.HistoryService; import org.sterl.spring.persistent_tasks.scheduler.SchedulerService; import org.sterl.spring.persistent_tasks.scheduler.component.EditSchedulerStatusComponent; import org.sterl.spring.persistent_tasks.scheduler.component.TaskExecutorComponent; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; import org.sterl.spring.persistent_tasks.task.TaskService; import org.sterl.spring.persistent_tasks.trigger.TriggerService; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/TaskSchedulerServiceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/TaskSchedulerServiceTest.java index 42839c766..1deece043 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/TaskSchedulerServiceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/TaskSchedulerServiceTest.java @@ -9,7 +9,7 @@ import org.sterl.spring.persistent_tasks.api.PersistentTask; import org.sterl.spring.persistent_tasks.api.RetryStrategy; import org.sterl.spring.persistent_tasks.api.TaskId; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; class TaskSchedulerServiceTest extends AbstractSpringTest { diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/history/HistoryServiceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/history/HistoryServiceTest.java index d0083e437..c57463b07 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/history/HistoryServiceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/history/HistoryServiceTest.java @@ -12,7 +12,7 @@ import org.sterl.spring.persistent_tasks.AbstractSpringTest; import org.sterl.spring.persistent_tasks.AbstractSpringTest.TaskConfig.Task3; import org.sterl.spring.persistent_tasks.api.AddTriggerRequest; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; class HistoryServiceTest extends AbstractSpringTest { @@ -54,4 +54,22 @@ void testTriggerHistory() throws TimeoutException, InterruptedException { assertThat(triggers.get(1).getData().getStatus()).isEqualTo(TriggerStatus.RUNNING); assertThat(triggers.get(2).getData().getStatus()).isEqualTo(TriggerStatus.WAITING); } + + @Test + void testTriggerHistoryTrx() throws TimeoutException, InterruptedException { + // GIVEN + final var trigger = Task3.ID.newUniqueTrigger("Hallo"); + persistentTaskService.queue(trigger); + // WHEN + hibernateAsserts.reset(); + schedulerService.triggerNextTasks().forEach(t -> { + try {t.get();} catch (Exception ex) {throw new RuntimeException(ex);} + }); + + // THEN + // 2 to get the work + // 1 for the running history + // 1 for the success history + hibernateAsserts.assertTrxCount(4); + } } diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepositoryTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepositoryTest.java deleted file mode 100644 index e82497ece..000000000 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryDetailRepositoryTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.sterl.spring.persistent_tasks.history.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.OffsetDateTime; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; -import org.sterl.spring.persistent_tasks.AbstractSpringTest; -import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryDetailEntity; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; - -class TriggerHistoryDetailRepositoryTest extends AbstractSpringTest { - - @Autowired private TriggerHistoryDetailRepository subject; - - @Test - void testGrouping() { - // GIVEN - var history1 = newHistoryEntry(TriggerStatus.RUNNING, OffsetDateTime.now()); - subject.save(history1); - var history2 = newHistoryEntry(TriggerStatus.RUNNING, OffsetDateTime.now()); - history2.setInstanceId(history1.getInstanceId()); - history2.getData().setKey(history1.getKey()); - subject.save(history2); - // AND - subject.save(newHistoryEntry(TriggerStatus.RUNNING, OffsetDateTime.now())); - - // WHEN - var result = subject.listHistoryOverview(PageRequest.of(0, 10)); - - // THEN - assertThat(result.getTotalElements()).isEqualTo(2L); - } - - private TriggerHistoryDetailEntity newHistoryEntry(TriggerStatus s, OffsetDateTime created) { - var history = pm.manufacturePojo(TriggerHistoryDetailEntity.class); - history.setId(null); - history.setCreatedTime(created); - history.getData().setStatus(s); - return history; - } - -} diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepositoryTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepositoryTest.java new file mode 100644 index 000000000..d52dd0c30 --- /dev/null +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/history/repository/TriggerHistoryLastStateRepositoryTest.java @@ -0,0 +1,75 @@ +package org.sterl.spring.persistent_tasks.history.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.OffsetDateTime; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.sterl.spring.persistent_tasks.AbstractSpringTest; +import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; +import org.sterl.spring.persistent_tasks.history.model.TriggerHistoryLastStateEntity; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; + +class TriggerHistoryLastStateRepositoryTest extends AbstractSpringTest { + + final AtomicLong idGenerator = new AtomicLong(0); + @Autowired + private TriggerHistoryLastStateRepository subject; + + @Test + void testListTriggerStatus() { + // GIVEN + subject.deleteAllInBatch(); + createStatus(new TriggerKey("1", "task1"), TriggerStatus.SUCCESS); + createStatus(new TriggerKey("2", "task1"), TriggerStatus.SUCCESS); + createStatus(new TriggerKey("3", "task1"), TriggerStatus.FAILED); + createStatus(new TriggerKey("4", "task2"), TriggerStatus.SUCCESS); + createStatus(new TriggerKey("5", "task2"), TriggerStatus.CANCELED); + assertThat(subject.count()).isEqualTo(5); + + // THEN + var result = subject.listTriggerStatus(); + + // WHEN + assertThat(result.size()).isEqualTo(4); + // AND + var i = 0; + assertThat(result.get(i).taskName()).isEqualTo("task1"); + assertThat(result.get(i).status()).isEqualTo(TriggerStatus.FAILED); + assertThat(result.get(i).executionCount()).isEqualTo(1L); + // AND + i = 1; + assertThat(result.get(i).taskName()).isEqualTo("task1"); + assertThat(result.get(i).status()).isEqualTo(TriggerStatus.SUCCESS); + assertThat(result.get(i).executionCount()).isEqualTo(2L); + // AND + i = 2; + assertThat(result.get(i).taskName()).isEqualTo("task2"); + assertThat(result.get(i).status()).isEqualTo(TriggerStatus.CANCELED); + assertThat(result.get(i).executionCount()).isEqualTo(1L); + } + + private TriggerHistoryLastStateEntity createStatus(TriggerKey key, TriggerStatus status) { + final var now = OffsetDateTime.now(); + final var isCancel = status == TriggerStatus.CANCELED; + + TriggerHistoryLastStateEntity result = new TriggerHistoryLastStateEntity(); + result.setId(idGenerator.incrementAndGet()); + result.setData(TriggerData + .builder() + .start(isCancel ? null : now.minusMinutes(1)) + .end(isCancel ? null : now) + .createdTime(now) + .key(key) + .status(status) + .runningDurationInMs(isCancel ? null : 600L) + .build() + ); + + return subject.save(result); + } + +} diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTest.java index 9acaaadb0..281042897 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTest.java @@ -15,8 +15,8 @@ import org.sterl.spring.persistent_tasks.api.TaskId; import org.sterl.spring.persistent_tasks.api.TaskId.TaskTriggerBuilder; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.scheduler.entity.SchedulerEntity; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; class SchedulerServiceTest extends AbstractSpringTest { diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java index 7a92eb0d5..104f01d9b 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java @@ -10,20 +10,23 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.support.TransactionTemplate; import org.sterl.spring.persistent_tasks.AbstractSpringTest; import org.sterl.spring.persistent_tasks.api.PersistentTask; import org.sterl.spring.persistent_tasks.api.RetryStrategy; import org.sterl.spring.persistent_tasks.api.TaskId.TaskTriggerBuilder; import org.sterl.spring.persistent_tasks.api.TransactionalTask; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.sample_app.person.PersonBE; import org.sterl.spring.sample_app.person.PersonRepository; +import org.sterl.test.Countdown; class SchedulerServiceTransactionTest extends AbstractSpringTest { private SchedulerService subject; - private static AtomicBoolean sendError = new AtomicBoolean(false); + private static final AtomicBoolean sendError = new AtomicBoolean(false); + private static final Countdown COUNTDOWN = new Countdown(); @Autowired private PersonRepository personRepository; @Configuration @@ -33,13 +36,8 @@ TransactionalTask savePersonInTrx(PersonRepository personRepository) { return new TransactionalTask() { @Override public void accept(String name) { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } personRepository.save(new PersonBE(name)); + COUNTDOWN.await(); if (sendError.get()) { throw new RuntimeException("Error requested for " + name); } @@ -49,16 +47,20 @@ public RetryStrategy retryStrategy() { } }; } - + @Bean - PersistentTask savePersonNoTrx(PersonRepository personRepository) { + PersistentTask savePersonNoTrx(TransactionTemplate trx, + PersonRepository personRepository) { return new PersistentTask<>() { @Override public void accept(String name) { - personRepository.save(new PersonBE(name)); - if (sendError.get()) { - throw new RuntimeException("Error requested for " + name); - } + trx.executeWithoutResult(t -> { + personRepository.save(new PersonBE(name)); + COUNTDOWN.await(); + if (sendError.get()) { + throw new RuntimeException("Error requested for " + name); + } + }); } public RetryStrategy retryStrategy() { return RetryStrategy.THREE_RETRIES_IMMEDIATELY; @@ -76,43 +78,68 @@ public void beforeEach() throws Exception { super.beforeEach(); subject = schedulerService; personRepository.deleteAllInBatch(); + COUNTDOWN.reset(); sendError.set(false); } @Test - void testSaveTransactions() throws Exception { + void testSaveNoTransactions() throws Exception { // GIVEN final var request = TaskTriggerBuilder.newTrigger("savePersonNoTrx").state("Paul").build(); var trigger = triggerService.queue(request); // WHEN hibernateAsserts.reset(); - triggerService.run(trigger); + COUNTDOWN.countDown(); + schedulerService.triggerNextTasks().forEach(t -> { + try {t.get();} catch (Exception ex) {throw new RuntimeException(ex);} + }); // THEN - // AND one the service, one the event and one more status update - hibernateAsserts.assertTrxCount(4); + // 1. get the trigger + // 2. one the event running + // 3. for the work + // 4. for success status + hibernateAsserts.assertTrxCount(5); assertThat(personRepository.count()).isOne(); + // AND + var data = persistentTaskService.getLastDetailData(trigger.key()); + assertThat(data.get().getStatus()).isEqualTo(TriggerStatus.SUCCESS); + // AND + var history = historyService.findAllDetailsForKey(trigger.key()).getContent(); + assertThat(history.get(0).getData().getStatus()).isEqualTo(TriggerStatus.SUCCESS); + assertThat(history.get(1).getData().getStatus()).isEqualTo(TriggerStatus.RUNNING); + assertThat(history.get(2).getData().getStatus()).isEqualTo(TriggerStatus.WAITING); } - @Test - void testTrxCountTriggerService() throws Exception { + void testSaveTransactions() throws Exception { // GIVEN final var request = TaskTriggerBuilder.newTrigger("savePersonInTrx").state("Paul").build(); var trigger = triggerService.queue(request); // WHEN hibernateAsserts.reset(); - triggerService.run(trigger); + COUNTDOWN.countDown(); + schedulerService.triggerNextTasks().forEach(t -> { + try {t.get();} catch (Exception ex) {throw new RuntimeException(ex);} + }); // THEN - hibernateAsserts.assertTrxCount(1); + hibernateAsserts.assertTrxCount(3); assertThat(personRepository.count()).isOne(); + // AND + var data = persistentTaskService.getLastDetailData(trigger.key()); + assertThat(data.get().getStatus()).isEqualTo(TriggerStatus.SUCCESS); + // AND + var history = historyService.findAllDetailsForKey(trigger.key()).getContent(); + assertThat(history.get(0).getData().getStatus()).isEqualTo(TriggerStatus.SUCCESS); + assertThat(history.get(1).getData().getStatus()).isEqualTo(TriggerStatus.RUNNING); + assertThat(history.get(2).getData().getStatus()).isEqualTo(TriggerStatus.WAITING); } @Test - void testFailTrxCount() throws Exception { + void test_fail_in_transaction() throws Exception { // GIVEN final var request = TaskTriggerBuilder.newTrigger("savePersonInTrx").state("Paul").build(); var trigger = triggerService.queue(request); @@ -120,17 +147,29 @@ void testFailTrxCount() throws Exception { // WHEN hibernateAsserts.reset(); - triggerService.run(trigger); + COUNTDOWN.countDown(); + schedulerService.triggerNextTasks().forEach(t -> { + try {t.get();} catch (Exception ex) {throw new RuntimeException(ex);} + }); // THEN - // first the work which runs on error - // second the update to the trigger - // third to write the history - hibernateAsserts.assertTrxCount(3); + // 1. Get the trigger + // 2. Running history + // 3. Run the trigger which will fail + // 4. Update the status to failed and write the history + hibernateAsserts.assertTrxCount(4); + // AND + var data = persistentTaskService.getLastDetailData(trigger.key()); + assertThat(data.get().getStatus()).isEqualTo(TriggerStatus.FAILED); + // AND + var history = historyService.findAllDetailsForKey(trigger.key()).getContent(); + assertThat(history.get(0).getData().getStatus()).isEqualTo(TriggerStatus.FAILED); + assertThat(history.get(1).getData().getStatus()).isEqualTo(TriggerStatus.RUNNING); + assertThat(history.get(2).getData().getStatus()).isEqualTo(TriggerStatus.WAITING); } @Test - void testRunOrQueue() throws Exception { + void testRunOrQueueShowsRunning() throws Exception { // GIVEN var k1 = subject.runOrQueue(TaskTriggerBuilder.newTrigger("savePersonInTrx").state("Paul").build()); var k2 = subject.runOrQueue(TaskTriggerBuilder.newTrigger("savePersonInTrx").state("Paul").build()); @@ -141,27 +180,42 @@ void testRunOrQueue() throws Exception { assertThat(persistentTaskService.getLastTriggerData(k2).get().getStatus()) .isEqualTo(TriggerStatus.RUNNING); - // THEN + Thread.sleep(150); // wait for the history async events + hibernateAsserts.assertTrxCount(7); + + // WHEN + COUNTDOWN.countDown(); awaitRunningTasks(); + // THEN assertThat(personRepository.count()).isEqualTo(2); + // AND + assertThat(persistentTaskService.getLastTriggerData(k1).get().getStatus()) + .isEqualTo(TriggerStatus.SUCCESS); + assertThat(persistentTaskService.getLastTriggerData(k2).get().getStatus()) + .isEqualTo(TriggerStatus.SUCCESS); } @Test void testRollbackAndRetry() throws Exception { // GIVEN - final var triggerRequest = TaskTriggerBuilder.newTrigger("savePersonInTrx").state("Paul").build(); + final var triggerRequest = TaskTriggerBuilder.newTrigger("savePersonInTrx") + .state("Paul").build(); sendError.set(true); // WHEN var key = subject.runOrQueue(triggerRequest); + COUNTDOWN.countDown(); + awaitRunningTasks(); // THEN - awaitRunningTasks(); - // AND the last status before we are back to running should be FAILED - assertThat(historyService.findAllDetailsForKey(key) - .getContent().get(0).getData().getStatus()) + var history = historyService.findAllDetailsForKey(key).getContent(); + assertThat(history.get(0).getData().getStatus()) .isEqualTo(TriggerStatus.FAILED); + assertThat(history.get(1).getData().getStatus()) + .isEqualTo(TriggerStatus.RUNNING); + assertThat(history.get(2).getData().getStatus()) + .isEqualTo(TriggerStatus.WAITING); // WHEN sendError.set(false); diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/TaskFailoverTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/TaskFailoverTest.java index a1548637b..0efcd9d0c 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/TaskFailoverTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/TaskFailoverTest.java @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.sterl.spring.persistent_tasks.AbstractSpringTest; import org.sterl.spring.persistent_tasks.api.TaskId; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; class TaskFailoverTest extends AbstractSpringTest { diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/task/TaskTransactionTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/task/TaskTransactionTest.java index b0d34d65d..879d0896f 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/task/TaskTransactionTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/task/TaskTransactionTest.java @@ -145,7 +145,7 @@ void testTransactionalTask(String task) { triggerService.run(t).get(); // THEN - hibernateAsserts.assertTrxCount(1); + hibernateAsserts.assertTrxCount(2); assertThat(personRepository.count()).isEqualTo(2); } } diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/TriggerServiceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/TriggerServiceTest.java index 3656ee70a..f130430ca 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/TriggerServiceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/TriggerServiceTest.java @@ -18,7 +18,8 @@ import org.sterl.spring.persistent_tasks.api.TaskId; import org.sterl.spring.persistent_tasks.api.TaskId.TaskTriggerBuilder; import org.sterl.spring.persistent_tasks.api.TriggerKey; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; +import org.sterl.spring.persistent_tasks.history.repository.TriggerHistoryLastStateRepository; import org.sterl.spring.persistent_tasks.task.repository.TaskRepository; import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; import org.sterl.spring.persistent_tasks.trigger.repository.TriggerRepository; @@ -31,6 +32,8 @@ class TriggerServiceTest extends AbstractSpringTest { private TriggerRepository triggerRepository; @Autowired private TaskRepository taskRepository; + @Autowired + private TriggerHistoryLastStateRepository triggerHistoryLastStateRepository; // ensure persistentTask in the spring context @Autowired @@ -58,8 +61,10 @@ void testAddTrigger() throws Exception { // THEN hibernateAsserts.assertTrxCount(1); - // one for the trigger and two for the history - hibernateAsserts.assertInsertCount(3); + // one for the trigger and just one for the history + hibernateAsserts.assertInsertCount(2); + // AND + assertThat(triggerHistoryLastStateRepository.count()).isZero(); // AND final var e = subject.get(triggerId); assertThat(e).isPresent(); diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java index bec8180c0..b468b3ff5 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java @@ -16,7 +16,7 @@ import org.sterl.spring.persistent_tasks.AbstractSpringTest.TaskConfig.Task3; import org.sterl.spring.persistent_tasks.api.TaskId.TaskTriggerBuilder; import org.sterl.spring.persistent_tasks.api.Trigger; -import org.sterl.spring.persistent_tasks.shared.model.TriggerStatus; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; class TriggerResourceTest extends AbstractSpringTest { diff --git a/core/src/test/java/org/sterl/test/Countdown.java b/core/src/test/java/org/sterl/test/Countdown.java new file mode 100644 index 000000000..b6bdd6830 --- /dev/null +++ b/core/src/test/java/org/sterl/test/Countdown.java @@ -0,0 +1,26 @@ +package org.sterl.test; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import org.awaitility.Awaitility; + +public class Countdown { + + private final AtomicInteger count = new AtomicInteger(1); + + public void await() { + Awaitility + .await("Countdown") + .atMost(Duration.ofSeconds(3)) + .until(() -> count.get() <= 0); + } + + public void countDown() { + count.decrementAndGet(); + } + + public void reset() { + count.set(1); + } +} diff --git a/example/src/main/java/org/sterl/spring/example_app/vehicle/task/BuildVehicleTask.java b/example/src/main/java/org/sterl/spring/example_app/vehicle/task/BuildVehicleTask.java index 6a263979a..adc3d998f 100644 --- a/example/src/main/java/org/sterl/spring/example_app/vehicle/task/BuildVehicleTask.java +++ b/example/src/main/java/org/sterl/spring/example_app/vehicle/task/BuildVehicleTask.java @@ -1,11 +1,13 @@ package org.sterl.spring.example_app.vehicle.task; +import java.util.Random; + import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.sterl.spring.example_app.vehicle.model.Vehicle; import org.sterl.spring.example_app.vehicle.repository.VehicleRepository; -import org.sterl.spring.persistent_tasks.api.SpringBeanTask; import org.sterl.spring.persistent_tasks.api.TaskId; +import org.sterl.spring.persistent_tasks.api.TransactionalTask; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,11 +15,11 @@ @Component(BuildVehicleTask.NAME) @RequiredArgsConstructor @Slf4j -public class BuildVehicleTask implements SpringBeanTask { +public class BuildVehicleTask implements TransactionalTask { static final String NAME = "buildVehicleTask"; public static final TaskId ID = new TaskId<>(NAME); - + private final Random random = new Random(); private final VehicleRepository vehicleRepository; @Transactional(timeout = 5) @@ -26,7 +28,7 @@ public void accept(Vehicle vehicle) { vehicleRepository.save(vehicle); log.info("Create vehicle ={}", vehicle); try { - Thread.sleep(1500); + Thread.sleep(random.nextInt(3501)); } catch (InterruptedException e) { Thread.interrupted(); } diff --git a/example/src/main/java/org/sterl/spring/example_app/vehicle/task/FailingBuildVehicleTask.java b/example/src/main/java/org/sterl/spring/example_app/vehicle/task/FailingBuildVehicleTask.java index 45c89aba3..f0c1eac8c 100644 --- a/example/src/main/java/org/sterl/spring/example_app/vehicle/task/FailingBuildVehicleTask.java +++ b/example/src/main/java/org/sterl/spring/example_app/vehicle/task/FailingBuildVehicleTask.java @@ -1,10 +1,12 @@ package org.sterl.spring.example_app.vehicle.task; +import java.util.Random; + import org.springframework.stereotype.Component; import org.sterl.spring.example_app.vehicle.model.Vehicle; import org.sterl.spring.example_app.vehicle.repository.VehicleRepository; -import org.sterl.spring.persistent_tasks.api.SpringBeanTask; import org.sterl.spring.persistent_tasks.api.TaskId; +import org.sterl.spring.persistent_tasks.api.TransactionalTask; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,11 +14,11 @@ @Component(FailingBuildVehicleTask.NAME) @RequiredArgsConstructor @Slf4j -public class FailingBuildVehicleTask implements SpringBeanTask { +public class FailingBuildVehicleTask implements TransactionalTask { static final String NAME = "failingBuildVehicleTask"; public static final TaskId ID = new TaskId<>(NAME); - + private final Random random = new Random(); private final VehicleRepository vehicleRepository; @Override @@ -24,10 +26,11 @@ public void accept(Vehicle vehicle) { vehicleRepository.save(vehicle); log.info("Create vehicle with {} - which will fail", vehicle); try { - Thread.sleep(3500); + Thread.sleep(random.nextInt(3501)); } catch (InterruptedException e) { Thread.interrupted(); } - throw new RuntimeException("This persistentTask will always fail!"); + if (random.nextInt(11) % 2 == 0) + throw new RuntimeException("This persistentTask will always fail!"); } } diff --git a/screenshots/history-screen.png b/screenshots/history-screen.png index 094d123d8..4952a6f8e 100644 Binary files a/screenshots/history-screen.png and b/screenshots/history-screen.png differ diff --git a/screenshots/schedulers-screen.png b/screenshots/schedulers-screen.png index 18df226bc..fb008fbe1 100644 Binary files a/screenshots/schedulers-screen.png and b/screenshots/schedulers-screen.png differ diff --git a/ui/src/history/history.page.tsx b/ui/src/history/history.page.tsx index 7f67aadce..69402a40b 100644 --- a/ui/src/history/history.page.tsx +++ b/ui/src/history/history.page.tsx @@ -7,7 +7,7 @@ import ReloadButton from "@src/shared/view/reload-button.view"; import TriggerItemView from "@src/shared/view/trigger-list-item.view"; import TaskSelect from "@src/task/view/task-select.view"; import { useState } from "react"; -import { Col, Form, Row, Stack } from "react-bootstrap"; +import { Accordion, Col, Form, Row, Stack } from "react-bootstrap"; const HistoryPage = () => { const [page, setPage] = useState(0); @@ -63,9 +63,11 @@ const HistoryPage = () => { /> - {triggers.data?.content.map((t) => ( - - ))} + + {triggers.data?.content.map((t) => ( + + ))} + ); diff --git a/ui/src/history/view/trigger-history.view.tsx b/ui/src/history/view/trigger-history.view.tsx index d1348c8fd..76e7e2b32 100644 --- a/ui/src/history/view/trigger-history.view.tsx +++ b/ui/src/history/view/trigger-history.view.tsx @@ -19,14 +19,11 @@ const TriggerHistoryListView = ({ triggers }: { triggers?: Trigger[] }) => { - - {" at " + formatDateTime(t.createdTime)} - - {formatDateTime(t.start)} - {formatDateTime(t.end)}{" "} + {formatDateTime(t.createdTime)} - {t.executionCount} + execution: {t.executionCount} {formatMs(t.runningDurationInMs)} diff --git a/ui/src/scheduler/scheduler.page.tsx b/ui/src/scheduler/scheduler.page.tsx index d91c1f46a..e4a89eb18 100644 --- a/ui/src/scheduler/scheduler.page.tsx +++ b/ui/src/scheduler/scheduler.page.tsx @@ -1,18 +1,25 @@ import SchedulerStatusView from "@src/scheduler/views/scheduler.view"; -import HttpErrorView from "@src/shared/view/http-error.view"; +import { TaskStatusHistoryOverview } from "@src/server-api"; +import { formatMs } from "@src/shared/date.util"; import { useServerObject } from "@src/shared/http-request"; import useAutoRefresh from "@src/shared/use-auto-refresh"; +import HttpErrorView from "@src/shared/view/http-error.view"; +import StatusView from "@src/task/view/staus.view"; import { useEffect } from "react"; -import { Col, Row } from "react-bootstrap"; +import { Card, Col, ListGroup, Row } from "react-bootstrap"; const SchedulersPage = () => { const schedulers = useServerObject( "/spring-tasks-api/schedulers" ); const tasks = useServerObject("/spring-tasks-api/tasks"); + const taskHistory = useServerObject( + "/spring-tasks-api/task-status-history" + ); - useEffect(tasks.doGet, []); - useAutoRefresh(10000, schedulers.doGet, []); + useEffect(() => tasks.doGet(), []); + useAutoRefresh(10000, () => schedulers.doGet(), []); + useAutoRefresh(10000, () => taskHistory.doGet(), []); return ( <> @@ -27,9 +34,58 @@ const SchedulersPage = () => { ))} + + {tasks.data?.map((i) => ( + + + + ))} ); }; - export default SchedulersPage; + +const TaskStatusHistoryOverviewView = ({ + name, + status, +}: { + name: string; + status: TaskStatusHistoryOverview[]; +}) => ( + + + {name} + + + {status + .filter((s) => s.taskName == name) + .map((s) => ( + + + + + + avg: {formatMs(s.avgDurationMs)} + max: {formatMs(s.maxDurationMs)} + + avg retry:{" "} + {Math.round( + Math.max(0, s.avgExecutionCount - 1) * 100 + ) / 100} + + + + ))} + + +); diff --git a/ui/src/server-api.d.ts b/ui/src/server-api.d.ts index e04a567b7..5404fa63f 100644 --- a/ui/src/server-api.d.ts +++ b/ui/src/server-api.d.ts @@ -23,17 +23,6 @@ export interface AddTriggerRequest { priority: number; } -export interface HistoryOverview { - instanceId: number; - taskName: string; - entryCount: number; - start: string; - end: string; - createdTime: string; - executionCount: number; - runningDurationInMs: number; -} - export interface PersistentTask { transactional: boolean; } @@ -60,6 +49,18 @@ export interface TaskId extends Serializable { export interface TaskTriggerBuilder { } +export interface TaskStatusHistoryOverview { + taskName: string; + status: TriggerStatus; + executionCount: number; + firstRun: string; + lastRun: string; + maxDurationMs: number; + minDurationMs: number; + avgDurationMs: number; + avgExecutionCount: number; +} + export interface TransactionalTask extends PersistentTask { } diff --git a/ui/src/shared/date.util.ts b/ui/src/shared/date.util.ts index 3d6addfed..db80d9cda 100644 --- a/ui/src/shared/date.util.ts +++ b/ui/src/shared/date.util.ts @@ -1,9 +1,14 @@ -export function formatDateTime(inputDate?: string | Date): string { +export function formatShortDateTime(inputDate?: string | Date): string { if (!inputDate) return ""; const date = inputDate instanceof Date ? inputDate : new Date(inputDate); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); + + const secondsPast = Math.floor(now.getTime() - date.getTime() / 1000); + if (secondsPast > 0 && secondsPast < 6) return "just now"; + if (secondsPast > 0 && secondsPast < 30) return secondsPast + "s"; + const options = { hour: "2-digit", minute: "2-digit", @@ -22,10 +27,28 @@ export function formatDateTime(inputDate?: string | Date): string { ).format(date); } +export function formatDateTime(inputDate?: string | Date): string { + if (!inputDate) return ""; + const date = inputDate instanceof Date ? inputDate : new Date(inputDate); + return new Intl.DateTimeFormat( + navigator.language || "en-US", + { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, // Use 24-hour format + } + ).format(date); +} + export function formatMs(ms?: number) { + if (ms === undefined || ms === null) return "-"; if (ms === 0) return "0ms"; - if (!ms) return ""; - if (ms < 9999) return ms + "ms"; + + if (ms < 9999) return Math.floor(ms) + "ms"; const inS = Math.floor(ms / 1000); if (ms < 99999) { diff --git a/ui/src/shared/view/trigger-list-item.view.tsx b/ui/src/shared/view/trigger-list-item.view.tsx index 2416278d6..30b9c6308 100644 --- a/ui/src/shared/view/trigger-list-item.view.tsx +++ b/ui/src/shared/view/trigger-list-item.view.tsx @@ -4,7 +4,7 @@ import LabeledText from "@src/shared/view/labled-text.view"; import JsonView from "@uiw/react-json-view"; import { Accordion, Button, Col, Container, Row } from "react-bootstrap"; import TriggerStatusView from "../../trigger/views/trigger-staus.view"; -import { formatDateTime, formatMs } from "../date.util"; +import { formatMs, formatShortDateTime } from "../date.util"; import { useServerObject } from "../http-request"; import HttpErrorView from "./http-error.view"; import StackTraceView from "./stacktrace-view"; @@ -28,57 +28,54 @@ const TriggerItemView = ({ trigger, afterTriggerChanged }: TriggerProps) => { ); return ( - { - if (!triggerHistory.data) triggerHistory.doGet(); - }} + triggerHistory.doGet()} > - - - - - - - - - {trigger.status === "WAITING" && afterTriggerChanged ? ( -
- - -
- ) : undefined} - + + -
-
-
+ + + + + {trigger.status === "WAITING" && afterTriggerChanged ? ( +
+ + +
+ ) : undefined} + +
+ ); }; @@ -95,10 +92,23 @@ const TriggerCompactView = ({ trigger }: { trigger: Trigger }) => ( {" " + trigger.key.taskName} - + - - + + {trigger.runningOn ? ( + + ) : ( + + )} ); @@ -113,19 +123,13 @@ const TriggerDetailsView = ({ return ( <> - - - - + - - + + - + @@ -133,19 +137,19 @@ const TriggerDetailsView = ({ diff --git a/ui/src/task/view/staus.view.tsx b/ui/src/task/view/staus.view.tsx new file mode 100644 index 000000000..c2134d017 --- /dev/null +++ b/ui/src/task/view/staus.view.tsx @@ -0,0 +1,21 @@ +import { TriggerStatus } from "@src/server-api"; +import { Badge } from "react-bootstrap"; + +interface Props { + status: TriggerStatus; + suffix?: string; +} +const StatusView = ({ status, suffix }: Props) => { + if (status === "SUCCESS") + return Success{suffix ?? ""}; + if (status === "FAILED") + return Failed{suffix ?? ""}; + if (status === "RUNNING") return Running{suffix ?? ""}; + if (status === "WAITING") + return Wating{suffix ?? ""}; + if (status === "CANCELED") + return Canceled{suffix ?? ""}; + return {status}; +}; + +export default StatusView; diff --git a/ui/src/trigger/triggers.page.tsx b/ui/src/trigger/triggers.page.tsx index e81dcca57..42343937a 100644 --- a/ui/src/trigger/triggers.page.tsx +++ b/ui/src/trigger/triggers.page.tsx @@ -6,7 +6,7 @@ import PageView from "@src/shared/view/page.view"; import ReloadButton from "@src/shared/view/reload-button.view"; import TaskSelect from "@src/task/view/task-select.view"; import { useState } from "react"; -import { Col, Form, Row, Stack } from "react-bootstrap"; +import { Accordion, Col, Form, Row, Stack } from "react-bootstrap"; import TriggerItemView from "../shared/view/trigger-list-item.view"; const TriggersPage = () => { @@ -56,13 +56,15 @@ const TriggersPage = () => { /> - {triggers.data?.content.map((t) => ( - - ))} + + {triggers.data?.content.map((t) => ( + + ))} + ); }; diff --git a/ui/test/shared/date.util.test.ts b/ui/test/shared/date.util.test.ts index f758cf04c..b7221f9f8 100644 --- a/ui/test/shared/date.util.test.ts +++ b/ui/test/shared/date.util.test.ts @@ -3,7 +3,7 @@ import { formatMs } from "@src/shared/date.util"; describe("Date Util Test", () => { it("formatMs", () => { - expect(formatMs(undefined)).toBe(""); + expect(formatMs(undefined)).toBe("-"); expect(formatMs(9000)).toBe("9000ms"); expect(formatMs(59*1000 + 50)).toBe("59s 50ms"); expect(formatMs(7*60*1000 + 50)).toBe("7min 0s");