Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## v2.4.0 - (2026-01-11)

### New features:

- Added cron trigger support
- Schedule tasks using cron expressions
- Suspend and resume cron jobs via API and UI
- New cron jobs management page in UI
- Updated UI dependencies
- Updated React to 19.2.3
- Updated all npm packages to latest versions
- Fixed security vulnerabilities (CVE-2025-62522, CVE-2025-64718)

## v2.3.1 - (2026-01-11)

### UI Improvements:
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Secondary goal is to support [Poor mans Workflow](https://github.com/sterlp/pmw)

1. **JPA-Powered Persistence** - Automatically persists tasks to your database
1. **Spring Boot Auto-Configuration** - Simple setup with @EnableSpringPersistentTasks
1. **Clustering Support** - Built-in lock management for distributed environments
1. **Clustering Support for tasks** - Built-in lock management for distributed environments and tasks
1. **Clustering Support for cron tasks** - Support for cron tasks in a cluster - run on just one node
1. **Type-Safe Tasks** - Generic task definitions with state objects
1. **Retry Mechanisms** - Automatic retry policies for failed executions
1. **Transactional Integration** - Works with Spring's transaction management
Expand Down Expand Up @@ -158,15 +159,15 @@ public class ExampleApplication {

## Schedulers

![Schedulers](screenshots/schedulers-screen.png)
![Schedulers](doc/docs/assets/dashboard.png)

## Triggers

![Triggers](screenshots/triggers-screen.png)
![Triggers](doc/docs/assets/triggers.png)

## History

![History](screenshots/history-screen.png)
![Triggers](doc/docs/assets/history-page.png)

# Alternatives

Expand Down
88 changes: 88 additions & 0 deletions core/code-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
## General rules

- Clean code apply
- Uncle Bob SOLID principle should be considered where useful
- if a method has more than 4 parameters consider an value/ entity class
- the zalando REST API rules apply
- run maven clean insall before a commit / end of work

## Testing

Each major functionality should be tested each test should be structured with. One test for each class with is called `subject` in the test to make clear this is the tested component.
Test should try to work with random data, so a clear shoud in general not be required.

// GIVEN
here is all the setup code and mybe any required special clear code for this test.

// WHEN
here is alle the action / modification code calling the service

// THEN
here comes all the assert code

## Component architecture

Each component is in its own package. Any access to a component has to be through the *Services classes - which are in the root directory of the folder.

All classes despite Model/Entity classes or converter are considered private and should not be used by any other component.
Each component is itself implemented by the help of

### Services classes e.g. PersonService
Position: Root Packge of the component

Represent the public internal API between components. Is usually annotated with @Service

- Implementiert den „Workflow“
- High Level Logik
- Aspekte wie
- Transaktions-management
- Autorisierung
- Caching
- Policies

Has usually no private methods. Private method are implemented by components.
A service should have a short description in the java doc - where its responsibility is state clearly to ensure no other component or services does the same.

Method names are more generic as the one used in repositories or components.
A Service may call other services.
A Service may call other components or repositories in their module/component.

### Component classes e.g. DeletePersonComponent

Position: sub package "component"

Implementiert den „Use-Case/ Step“. Usually named like the method itself. In general has only one responsibility and usually only one "execute" method. May have serveral methods. but always to access this one logic in different ways.
Otherwise the method names are very specific.

Low Level Logik
Wie eine private Methode im Service
Sollte alleine testbar sein

- A component may call other components in their module/component.
- As a component is a low lever building part of the application it is not allowed to delegate the work up to the services of its own component.
- But a component may call other functionality from other components through foreign Services classes.

### Repository classes e.g. PersonRepository
Position: sub package "repository"

- Implementiert die „Persistenz“
- Abstraktion der Datenbank
- Queries

### Resource class e.g. PersonResource

Position: sub package "api"

Implements the public interface and delegates to the service. It it the anty corruption layer to the ourside world, it has no business logic and only converts from the external API to the internal entity classes.

### Entity class e.g. PersonEntity
Position: sub package "model"

Represent the "DTO" or "DB Entities" of the components. They are suffixed with "Entity" to ensure no name collision with the API classes, which has no suffix.
These classes can be shared between components but any modification or creation to this class has to be done by the Service owning this entity.

### Timers e.g. PersonTimer

Position: either root package with the service or sub package "timer"

A Timer should only be an internal way to call the service. Itself has no logic despite logging. Any logic sould be in the Service or if it is more complex in an Component class, or several, which is/are used by the Service.
8 changes: 7 additions & 1 deletion core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<parent>
<groupId>org.sterl.spring</groupId>
<artifactId>spring-persistent-tasks-root</artifactId>
<version>2.3.2-SNAPSHOT</version>
<version>2.4.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down Expand Up @@ -152,6 +152,12 @@
<class>java.io.Serializable</class>
<class>org.sterl.spring.persistent_tasks.api.TaskId</class>
<class>org.sterl.spring.persistent_tasks.api.RetryStrategy</class>
<class>org.sterl.spring.persistent_tasks.api.CronSchedule</class>
<class>org.sterl.spring.persistent_tasks.api.Schedule</class>
<class>org.sterl.spring.persistent_tasks.api.CronTriggerBuilder</class>
<class>org.sterl.spring.persistent_tasks.api.TriggerBuilder</class>
<class>org.sterl.spring.persistent_tasks.api.IntervalSchedule</class>
TemporalUnit
</excludeClasses>
<outputFile>../ui/spt-ui-lib/lib/server-api.ts</outputFile>
<outputKind>module</outputKind>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.sterl.spring.persistent_tasks.shared.model.HasTrigger;
import org.sterl.spring.persistent_tasks.shared.model.TriggerEntity;
import org.sterl.spring.persistent_tasks.trigger.TriggerService;
import org.sterl.spring.persistent_tasks.trigger.model.CronTriggerEntity;
import org.sterl.spring.persistent_tasks.trigger.model.RunningTriggerEntity;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -166,4 +167,15 @@ public Optional<HistoryTriggerEntity> getLastTriggerHistory(Long id) {
if (result.isEmpty()) return Optional.empty();
return Optional.of(result.getContent().getFirst());
}

/**
* Adds a {@link CronTriggerEntity} for recurring tasks.
* It will also add the {@link TriggerEntity} if required.
*
* @param cronTrigger the {@link CronTriggerEntity} to manage
* @return <code>true</code> if a new {@link TriggerEntity} was created, <code>false</code> if one already exists
*/
public boolean register(CronTriggerEntity<? extends Serializable> cron) {
return triggerService.register(cron);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.sterl.spring.persistent_tasks.api;

import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;

import org.springframework.scheduling.support.CronExpression;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;

/**
* A schedule based on a cron expression. Always uses UTC timezone.
* <p>
* Examples:
* <ul>
* <li>"0 0 2 * * *" - Every day at 2:00 AM UTC</li>
* <li>"0 0 * * * *" - Every hour on the hour</li>
* <li>"0 *&#47;15 * * * *" - Every 15 minutes</li>
* </ul>
*/
@EqualsAndHashCode(of = "expression")
@ToString(of = "expression")
public final class CronSchedule implements Schedule {
private static final long serialVersionUID = 1L;

@Getter
private final String expression;
private transient CronExpression cronExpression;

/**
* Creates a new cron schedule.
*
* @param expression the cron expression (6 fields: second minute hour day month weekday)
* @throws IllegalArgumentException if the cron expression is invalid
*/
public CronSchedule(String expression) {
if (expression == null || expression.trim().isEmpty()) {
throw new IllegalArgumentException("Cron expression cannot be null or empty");
}
this.expression = expression.trim();
this.cronExpression = CronExpression.parse(this.expression);
}

@Override
public OffsetDateTime next(OffsetDateTime from) {
if (cronExpression == null) {
cronExpression = CronExpression.parse(expression);
}

var next = cronExpression.next(OffsetDateTime.now());

if (next == null) {
throw new IllegalStateException("No next execution time found for cron: " + expression);
}

return next.truncatedTo(ChronoUnit.SECONDS);
}

@Override
public String description() {
return "cron(" + expression + ")";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.sterl.spring.persistent_tasks.api;

import java.io.Serializable;
import java.time.Duration;
import java.util.function.Supplier;

import org.sterl.spring.persistent_tasks.trigger.model.CronTriggerEntity;

import lombok.AccessLevel;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;

/**
* Fluent builder for creating scheduled (recurring) triggers.
*/
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class CronTriggerBuilder<T extends Serializable> {
@NonNull
private final TaskId<T> taskId;
private String id;
private Schedule schedule;
private Supplier<T> stateProvider;
private String tag;
private int priority = TriggerRequest.DEFAULT_PRIORITY;

/**
* Sets the unique ID for this scheduled trigger.
* If not set, defaults to {@link Schedule#description()}.
*/
public CronTriggerBuilder<T> id(String id) {
this.id = id;
return this;
}

/**
* Sets a fixed state for all trigger executions.
*/
public CronTriggerBuilder<T> state(T state) {
this.stateProvider = () -> state;
return this;
}

/**
* Sets a state provider that's called for each trigger execution.
* Useful for dynamic state generation.
*/
public CronTriggerBuilder<T> stateProvider(Supplier<T> stateProvider) {
this.stateProvider = stateProvider;
return this;
}

/**
* Sets the schedule using a cron expression.
*
* @param cronExpression cron expression (6 fields: second minute hour day month weekday)
*/
public CronTriggerBuilder<T> cron(String cronExpression) {
this.schedule = new CronSchedule(cronExpression);
return this;
}

/**
* Sets the schedule using a fixed interval after each invocation.
*
* @param interval duration between executions
*/
public CronTriggerBuilder<T> after(Duration interval) {
this.schedule = new IntervalSchedule(interval);
return this;
}

/**
* Sets a custom tag for created triggers.
*/
public CronTriggerBuilder<T> tag(String tag) {
this.tag = tag;
return this;
}

/**
* Sets the priority for created triggers (higher = more important).
*/
public CronTriggerBuilder<T> priority(int priority) {
this.priority = priority;
return this;
}

public CronTriggerEntity<T> build() {
if (schedule == null) {
throw new IllegalStateException("Schedule must be set using .cron() or .every()");
}
if (id == null) {
id = schedule.description();
}

return CronTriggerEntity.<T>builder()
.id(id)
.taskId(taskId)
.schedule(schedule)
.stateProvider(stateProvider)
.tag(tag == null ? "cron" : tag)
.priority(priority)
.build();

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.sterl.spring.persistent_tasks.api;

import org.springframework.lang.Nullable;

import lombok.Data;

/**
* Representing a cron trigger definition registered in-memory.
*/
@Data
public class CronTriggerInfo {

/** Unique ID of this cron trigger */
private String id;

/** The task that this cron trigger executes */
private String taskName;

/** The schedule (cron expression or interval description) */
private String schedule;

/** Tag for the created triggers */
@Nullable
private String tag;

/** Priority for the created triggers */
private int priority = 5;

/** Whether this cron trigger is suspended (not creating new triggers) */
private boolean suspended = false;

/** Whether this cron trigger has a state provider configured */
private boolean hasStateProvider = false;
}
Loading