Add SPI option to setup the start time of the workflows step runner task

Closes #47540

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2026-03-27 14:03:50 -03:00 committed by Pedro Igor
parent 87d8fbe521
commit d24d2697aa
4 changed files with 79 additions and 4 deletions

View file

@ -27,7 +27,14 @@ By default, the background task tracking due steps runs every 12 hours, but this
* `spi-events-listener--workflow-event-listener--step-runner-task-interval`: Defines the interval at which the background task runs to check for workflow steps that are due for execution.
It follows the same format used in the workflow step `after` field, where you can specify the interval as a number followed by a time unit (`ms`, `s` - default, `m`, `h`, `d`) or using the ISO-8601 duration format.
You can adjust this interval based on your realm's needs and the expected frequency of workflow executions.
By default, the first execution of the background task occurs after one full interval has elapsed since the server started.
You can control this by setting a start time that acts as an anchor for aligning executions to a predictable schedule:
* `spi-events-listener--workflow-event-listener--step-runner-task-start-time`: Defines the time of day used to align the background task executions, in `HH:mm` format (e.g., `02:00`, `14:30`), using the server's local timezone.
When a start time is set, the task interval is aligned to a grid of execution times anchored at the specified time. For example, with a start time of `02:00` and an interval of `12h`, the task always runs at 02:00 and 14:00 regardless of when the server was started. If the server starts at 10:30, the first execution would occur at 14:00.
You can adjust these options based on your realm's needs and the expected frequency of workflow executions.
== Configuring the task execution timeout

View file

@ -28,10 +28,26 @@ public interface TimerProvider extends Provider {
public void schedule(Runnable runnable, long intervalMillis, String taskName);
/**
* Schedule a task with an initial delay that differs from the interval.
*
* @param runnable the task to run
* @param initialDelayMillis delay before the first execution
* @param intervalMillis interval between subsequent executions
* @param taskName unique name for the task
*/
default void schedule(Runnable runnable, long initialDelayMillis, long intervalMillis, String taskName) {
schedule(runnable, intervalMillis, taskName);
}
default void schedule(TaskRunner runner, long intervalMillis) {
schedule(runner, intervalMillis, runner.getTaskName());
}
default void schedule(TaskRunner runner, long initialDelayMillis, long intervalMillis) {
schedule(runner, initialDelayMillis, intervalMillis, runner.getTaskName());
}
public void scheduleTask(ScheduledTask scheduledTask, long intervalMillis, String taskName);
public default void scheduleTask(ScheduledTask scheduledTask, long intervalMillis) {

View file

@ -18,6 +18,9 @@
package org.keycloak.models.workflow;
import java.time.Duration;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import org.keycloak.Config.Scope;
import org.keycloak.common.Profile;
@ -31,11 +34,16 @@ import org.keycloak.provider.ProviderEvent;
import org.keycloak.services.scheduled.ClusterAwareScheduledTaskRunner;
import org.keycloak.timer.TimerProvider;
import org.jboss.logging.Logger;
public class WorkflowsEventListenerFactory implements EventListenerProviderFactory, EnvironmentDependentProviderFactory {
private static final Logger logger = Logger.getLogger(WorkflowsEventListenerFactory.class);
public static final String ID = "workflow-event-listener";
private static final long DEFAULT_STEP_RUNNER_TASK_INTERVAL = Duration.ofHours(12).toMillis();
private long stepRunnerTaskInterval;
private LocalTime stepRunnerTaskStartTime;
@Override
public EventListenerProvider create(KeycloakSession session) {
@ -51,6 +59,16 @@ public class WorkflowsEventListenerFactory implements EventListenerProviderFacto
public void init(Scope config) {
String taskIntervalStr = config.get("stepRunnerTaskInterval");
this.stepRunnerTaskInterval = taskIntervalStr == null ? DEFAULT_STEP_RUNNER_TASK_INTERVAL : DurationConverter.parseDuration(taskIntervalStr).toMillis();
String startTimeStr = config.get("stepRunnerTaskStartTime");
if (startTimeStr != null) {
try {
this.stepRunnerTaskStartTime = LocalTime.parse(startTimeStr);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid stepRunnerTaskStartTime value '" + startTimeStr
+ "'. Expected format: HH:mm (e.g., 02:00, 14:30)", e);
}
}
}
@Override
@ -85,9 +103,38 @@ public class WorkflowsEventListenerFactory implements EventListenerProviderFacto
}
private void scheduleStepRunnerTask(KeycloakSessionFactory factory) {
long initialDelay = computeInitialDelay();
try (KeycloakSession session = factory.create()) {
TimerProvider timer = session.getProvider(TimerProvider.class);
timer.schedule(new ClusterAwareScheduledTaskRunner(factory, new WorkflowRunnerScheduledTask(factory), stepRunnerTaskInterval), stepRunnerTaskInterval);
ClusterAwareScheduledTaskRunner runner = new ClusterAwareScheduledTaskRunner(factory,
new WorkflowRunnerScheduledTask(factory), stepRunnerTaskInterval);
timer.schedule(runner, initialDelay, stepRunnerTaskInterval);
}
ZonedDateTime nextExecution = ZonedDateTime.now().plus(Duration.ofMillis(initialDelay));
logger.infof("Workflow runner task scheduled: next execution at %s, then every %s",
nextExecution.toLocalTime().withNano(0),
Duration.ofMillis(stepRunnerTaskInterval));
}
/**
* Computes the initial delay before the first task execution.
* <p>
* If a start time is configured, it is used as an anchor to align executions to a predictable
* schedule. For example, with a start time of 18:00 and an interval of 2 hours, the execution
* grid is 00:00, 02:00, 04:00, ..., 16:00, 18:00, 20:00, 22:00. The initial delay is calculated
* so that the first execution occurs at the next grid point after the current time.
* <p>
* If no start time is configured, the initial delay equals the interval (current default behavior).
*/
long computeInitialDelay() {
if (stepRunnerTaskStartTime == null) {
return stepRunnerTaskInterval;
}
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime anchor = now.toLocalDate().atTime(stepRunnerTaskStartTime).atZone(now.getZone());
long millisPastLastGridPoint = Math.floorMod(Duration.between(anchor, now).toMillis(), stepRunnerTaskInterval);
return stepRunnerTaskInterval - millisPastLastGridPoint;
}
}

View file

@ -52,6 +52,11 @@ public class BasicTimerProvider implements TimerProvider {
@Override
public void schedule(final Runnable runnable, final long intervalMillis, String taskName) {
schedule(runnable, intervalMillis, intervalMillis, taskName);
}
@Override
public void schedule(final Runnable runnable, final long initialDelayMillis, final long intervalMillis, String taskName) {
TimerTask task = new BasicTimerTask(runnable);
TimerTaskContextImpl taskContext = new TimerTaskContextImpl(runnable, task, Time.currentTimeMillis(), intervalMillis);
@ -61,8 +66,8 @@ public class BasicTimerProvider implements TimerProvider {
existingTask.timerTask.cancel();
}
logger.debugf("Starting task '%s' with interval '%d'", taskName, intervalMillis);
timer.schedule(task, intervalMillis, intervalMillis);
logger.debugf("Starting task '%s' with initial delay '%d' and interval '%d'", taskName, initialDelayMillis, intervalMillis);
timer.schedule(task, initialDelayMillis, intervalMillis);
}
@Override