From 0a84d0ead6a7844478ad3b2ca44fca64f33c4979 Mon Sep 17 00:00:00 2001
From: moqui-industrial <729502+moqui-industrial@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:03:32 +0200
Subject: [PATCH] Add scheduled service API and distributed executor
configuration
---
.../service/ServiceCallScheduledImpl.groovy | 419 ++++++++++++++++++
.../impl/service/ServiceFacadeImpl.groovy | 93 +++-
.../moqui/service/ServiceCallScheduled.java | 109 +++++
.../java/org/moqui/service/ServiceFacade.java | 11 +-
.../src/main/resources/MoquiDefaultConf.xml | 72 ++-
framework/template/XmlActions.groovy.ftl | 35 ++
framework/xsd/moqui-conf-3.xsd | 23 +-
framework/xsd/xml-actions-3.xsd | 88 +++-
8 files changed, 841 insertions(+), 9 deletions(-)
create mode 100644 framework/src/main/groovy/org/moqui/impl/service/ServiceCallScheduledImpl.groovy
create mode 100644 framework/src/main/java/org/moqui/service/ServiceCallScheduled.java
diff --git a/framework/src/main/groovy/org/moqui/impl/service/ServiceCallScheduledImpl.groovy b/framework/src/main/groovy/org/moqui/impl/service/ServiceCallScheduledImpl.groovy
new file mode 100644
index 000000000..920fc7b8b
--- /dev/null
+++ b/framework/src/main/groovy/org/moqui/impl/service/ServiceCallScheduledImpl.groovy
@@ -0,0 +1,419 @@
+/*
+ * This software is in the public domain under CC0 1.0 Universal plus a
+ * Grant of Patent License.
+ *
+ * To the extent possible under law, the author(s) have dedicated all
+ * copyright and related and neighboring rights to this software to the
+ * public domain worldwide. This software is distributed without any
+ * warranty.
+ *
+ * You should have received a copy of the CC0 Public Domain Dedication
+ * along with this software (see the LICENSE.md file). If not, see
+ * .
+ */
+package org.moqui.impl.service
+
+import groovy.transform.CompileStatic
+import org.moqui.Moqui
+import org.moqui.impl.context.ExecutionContextFactoryImpl
+import org.moqui.impl.context.ExecutionContextImpl
+import org.moqui.service.ServiceCallScheduled
+import org.moqui.service.ServiceException
+import org.moqui.service.ServiceCallSync
+
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+import java.util.concurrent.Callable
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+
+@CompileStatic
+class ServiceCallScheduledImpl extends ServiceCallImpl implements ServiceCallScheduled {
+ protected final static Logger logger = LoggerFactory.getLogger(ServiceCallScheduledImpl.class)
+
+ protected String taskName = null
+ protected ScheduledServiceInfo scheduledServiceInfo = null
+ protected ScheduledFuture scheduledFuture = null
+ protected boolean isOneShot = true
+ protected boolean isPeriodic = false
+ protected TimeUnit unit = TimeUnit.MILLISECONDS
+ protected Long initialDelay = 0L
+ protected Integer transactionTimeout = null
+ protected Long period
+ protected Long delay
+ protected Long duration
+
+ ServiceCallScheduledImpl(ServiceFacadeImpl sfi) {
+ super(sfi)
+ }
+
+ ServiceCallScheduledImpl(ServiceFacadeImpl sfi, String taskName) {
+ super(sfi)
+ this.taskName(taskName)
+ }
+
+ @Override
+ ServiceCallScheduled name(String serviceName) { serviceNameInternal(serviceName); return this }
+ @Override
+ ServiceCallScheduled name(String v, String n) { serviceNameInternal(null, v, n); return this }
+ @Override
+ ServiceCallScheduled name(String p, String v, String n) { serviceNameInternal(p, v, n); return this }
+
+ @Override
+ ServiceCallScheduled parameters(Map map) { parameters.putAll(map); return this }
+ @Override
+ ServiceCallScheduled parameter(String name, Object value) { parameters.put(name, value); return this }
+
+ @Override
+ String getTaskName() { return this.taskName }
+
+ @Override
+ ServiceCallScheduled taskName(String taskName) {
+ if (taskName == null) throw new IllegalArgumentException("The argument taskName is null.")
+ this.taskName = taskName
+ // try to get and restore existing scheduledFuture by taskName
+ this.scheduledFuture = sfi.getScheduledFuture(taskName)
+ return this
+ }
+
+ @Override
+ ServiceCallScheduled timeUnit(TimeUnit unit) { this.unit = unit; return this }
+
+ @Override
+ ServiceCallScheduled initialDelay(long delay) { this.initialDelay = delay; return this }
+
+ @Override
+ ServiceCallScheduled transactionTimeout(int transactionTimeout) { this.transactionTimeout = transactionTimeout; return this }
+
+ @Override
+ ServiceCallScheduled atFixedRate(long period) { this.period = period; this.delay = null; this.isOneShot = false; this.isPeriodic = true; return this }
+
+ @Override
+ ServiceCallScheduled withFixedDelay(long delay) { this.delay = delay; this.period = null; this.isOneShot = false; this.isPeriodic = true; return this }
+
+ @Override
+ ServiceCallScheduled duration(long duration) { this.duration = duration; return this }
+
+ @Override
+ ServiceCallScheduled call() throws ServiceException {
+ if (!taskName) throw new IllegalStateException("taskName is required for call()")
+
+ // guard: if we already have a running future for this builder, return
+ if (scheduledFuture != null && this.isRunning()) {
+ logger.warn("A previous scheduled service call [${taskName}] is still running. To terminate the previous scheduling use the cancel() method.")
+ return this
+ }
+ // guard: if another task with the same name is already scheduled, reuse it and return
+ ScheduledFuture existing = sfi.getScheduledFuture(taskName)
+ if (existing != null && !existing.isCancelled() && !existing.isDone()) {
+ logger.warn("A previous scheduled service call [${taskName}] is still running. To terminate the previous scheduling use the cancel() method.")
+ this.scheduledFuture = existing
+ return this
+ }
+
+ ExecutionContextFactoryImpl ecfi = sfi.ecfi
+ ExecutionContextImpl eci = ecfi.getEci()
+ validateCall(eci)
+
+ // runs the scheduled service and gets the scheduleFuture
+ ScheduledServiceRunnable runnable
+ if (transactionTimeout != null) {
+ runnable = new ScheduledServiceRunnable(eci, serviceName, parameters, taskName, transactionTimeout)
+ } else {
+ runnable = new ScheduledServiceRunnable(eci, serviceName, parameters, taskName)
+ }
+ scheduledServiceInfo = runnable
+
+ if (isOneShot) {
+ scheduledFuture = sfi.scheduledExecutor.schedule(runnable, initialDelay, unit)
+ } else if (isPeriodic) {
+ if (period != null && period >= 0) {
+ scheduledFuture = sfi.scheduledExecutor.scheduleAtFixedRate(runnable, initialDelay, period, unit)
+ if (duration > 0) cancel(false, duration, unit)
+ } else if (delay != null && delay >= 0) {
+ scheduledFuture = sfi.scheduledExecutor.scheduleWithFixedDelay(runnable, initialDelay, delay, unit)
+ if (duration > 0) cancel(false, duration, unit)
+ } else {
+ throw new IllegalStateException("Periodic scheduling requested but neither period nor delay was set for task [${taskName}].")
+ }
+ } else {
+ throw new IllegalStateException("Schedule mode not set (one-shot/periodic) for task [${taskName}].")
+ }
+
+ if (scheduledFuture == null) throw new IllegalStateException("Scheduler returned null ScheduledFuture for task [${taskName}].")
+
+ sfi.putScheduledFuture(taskName, scheduledFuture)
+ return this
+ }
+
+ @Override
+ ScheduledFuture