diff --git a/docs/documentation/server_admin/topics/workflows/understanding-workflows-engine.adoc b/docs/documentation/server_admin/topics/workflows/understanding-workflows-engine.adoc index ca99a75ecb4..6dd1673c4d5 100644 --- a/docs/documentation/server_admin/topics/workflows/understanding-workflows-engine.adoc +++ b/docs/documentation/server_admin/topics/workflows/understanding-workflows-engine.adoc @@ -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 diff --git a/server-spi-private/src/main/java/org/keycloak/timer/TimerProvider.java b/server-spi-private/src/main/java/org/keycloak/timer/TimerProvider.java index 3917b82f42e..bbe4d84516c 100644 --- a/server-spi-private/src/main/java/org/keycloak/timer/TimerProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/timer/TimerProvider.java @@ -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) { diff --git a/services/src/main/java/org/keycloak/models/workflow/WorkflowsEventListenerFactory.java b/services/src/main/java/org/keycloak/models/workflow/WorkflowsEventListenerFactory.java index 583cb749364..40d633f2ded 100644 --- a/services/src/main/java/org/keycloak/models/workflow/WorkflowsEventListenerFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/WorkflowsEventListenerFactory.java @@ -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. + *

+ * 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. + *

+ * 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; } } diff --git a/services/src/main/java/org/keycloak/timer/basic/BasicTimerProvider.java b/services/src/main/java/org/keycloak/timer/basic/BasicTimerProvider.java index 6ad43a8913d..54c3e3b302a 100644 --- a/services/src/main/java/org/keycloak/timer/basic/BasicTimerProvider.java +++ b/services/src/main/java/org/keycloak/timer/basic/BasicTimerProvider.java @@ -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