From 5444766c473eea7b9bcb4816a3ed2a6d955f49d2 Mon Sep 17 00:00:00 2001 From: Ravi Nadahar Date: Sun, 7 Jun 2026 19:32:40 +0200 Subject: [PATCH 1/3] Enable asynchronous execution of rules Signed-off-by: Ravi Nadahar --- .../openhab/core/automation/RuleManager.java | 42 +++++ .../automation/internal/RuleEngineImpl.java | 156 +++++++++++++----- 2 files changed, 158 insertions(+), 40 deletions(-) diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/RuleManager.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/RuleManager.java index 1567ae75681..343b7973ddd 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/RuleManager.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/RuleManager.java @@ -14,6 +14,7 @@ import java.time.ZonedDateTime; import java.util.Map; +import java.util.concurrent.Future; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -100,6 +101,47 @@ public interface RuleManager { Map runNow(String uid, boolean considerConditions, @Nullable Map context); + /** + * The method skips triggers and conditions and executes the actions of the rule asynchronously. + * This should always be possible unless an action has a mandatory input that is linked to a trigger. + * In that case the action is skipped and the rule engine continues execution of remaining actions. + *

+ * Note: Unlike {@link #runNow(String)}, this method will return immediately. To wait for the execution to be + * completed, call {@link Future#get()} on the returned {@link Future}. + * + * @param ruleUID uid of the rule whose actions should be executed. + * @return A {@link Future} containing the copy of the rule context after completion, including possible return + * values. + * @throws UnsupportedOperationException If asynchronous execution isn't supported by the {@link RuleManager} + * implementation. + * + * @implNote The default implementation simply calls {@link #runAsync(String, boolean, Map)}. + */ + default Future> runAsync(String ruleUID) { + return runAsync(ruleUID, false, null); + } + + /** + * Same as {@link #runAsync(String)} with the additional option to enable/disable evaluation of + * conditions defined in the target rule. The context can be set here, too, but might also be {@code null}. + *

+ * Note: Unlike {@link #runNow(String, boolean, Map)}, this method will return immediately. To wait for the + * execution to be completed, call {@link Future#get()} on the returned {@link Future}. + * + * @param ruleUID uid of the rule whose actions should be executed. + * @param considerConditions if {@code true} the conditions of the rule will be checked. + * @param context the context that is passed to the conditions and the actions of the rule. + * @return a copy of the rule context, including possible return values + * @throws UnsupportedOperationException If asynchronous execution isn't supported by the {@link RuleManager} + * implementation. + * + * @implNote The default implementation throws an {@link UnsupportedOperationException}. + */ + default Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + throw new UnsupportedOperationException("runAsync() isn't implemented by " + getClass().getName()); + } + /** * Simulates the execution of all rules with tag 'Schedule' for the given time interval. * The result is sorted ascending by execution time. diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java index 7c2e67ae71f..c0e95201d4d 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java @@ -24,6 +24,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ExecutionException; @@ -923,8 +925,8 @@ private boolean activateRule(final WrappedRule rule) { if (started && slTriggers.stream() .anyMatch(t -> ((BigDecimal) t.getConfiguration().get(SystemTriggerHandler.CFG_STARTLEVEL)) .intValue() <= startLevel)) { - runNow(rule.getUID(), true, Map.of(SystemTriggerHandler.OUT_STARTLEVEL, StartLevelService.STARTLEVEL_RULES, - "event", SystemEventFactory.createStartlevelEvent(startLevel))); + runAsync(rule.getUID(), true, Map.of(SystemTriggerHandler.OUT_STARTLEVEL, + StartLevelService.STARTLEVEL_RULES, "event", SystemEventFactory.createStartlevelEvent(startLevel))); } return true; @@ -1122,9 +1124,9 @@ protected void runRule(String ruleUID, TriggerHandlerCallbackImpl.TriggerData td boolean isSatisfied = calculateConditions(rule); if (isSatisfied) { executeActions(rule, true); - logger.debug("The rule '{}' is executed.", ruleUID); + logger.debug("The rule '{}' was executed.", ruleUID); } else { - logger.debug("The rule '{}' is NOT executed, since it has unsatisfied conditions.", ruleUID); + logger.debug("The rule '{}' was NOT executed, since it has unsatisfied conditions.", ruleUID); } } } catch (Throwable t) { @@ -1164,39 +1166,7 @@ protected void runRule(String ruleUID, TriggerHandlerCallbackImpl.TriggerData td Future> future; try { - future = thCallback.getScheduler().submit(() -> { - Map returnContext = new HashMap<>(); - synchronized (this) { - final RuleStatus ruleStatus = getRuleStatus(ruleUID); - if (ruleStatus != null && ruleStatus != RuleStatus.IDLE) { - logger.error("Failed to execute rule '{}' with status '{}'", ruleUID, ruleStatus.name()); - return Map.of(); - } - // change state to RUNNING - setStatus(ruleUID, new RuleStatusInfo(RuleStatus.RUNNING)); - } - try { - clearContext(ruleUID); - if (context != null && !context.isEmpty()) { - getContext(ruleUID, null).putAll(context); - } - if (!considerConditions || calculateConditions(rule)) { - executeActions(rule, false); - } - logger.debug("The rule '{}' is executed.", ruleUID); - returnContext.putAll(getContext(ruleUID, null)); - } catch (Throwable t) { - logger.error("Failed to execute rule '{}': ", ruleUID, t); - } finally { - // change state to IDLE only if the rule has not been DISABLED. - synchronized (this) { - if (getRuleStatus(ruleUID) == RuleStatus.RUNNING) { - setStatus(ruleUID, new RuleStatusInfo(RuleStatus.IDLE)); - } - } - } - return returnContext; - }); + future = thCallback.getScheduler().submit(new RunRuleCallable(rule, considerConditions, context)); } catch (RejectedExecutionException e) { logger.warn("Failed to execute rule '{}' because the rule executor rejected execution: {}", ruleUID, e.getMessage()); @@ -1219,6 +1189,46 @@ protected void runRule(String ruleUID, TriggerHandlerCallbackImpl.TriggerData td return runNow(ruleUID, false, null); } + @Override + public Future> runAsync(String ruleUID) { + return runAsync(ruleUID, false, null); + } + + @Override + public Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + final WrappedRule rule = getManagedRule(ruleUID); + if (rule == null) { + logger.warn("Failed to execute rule '{}': Invalid rule UID", ruleUID); + return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid rule UID: " + ruleUID)); + } + + TriggerHandlerCallbackImpl thCallback; + synchronized (this) { + thCallback = thCallbacks.get(ruleUID); + } + if (thCallback == null) { + RuleStatus ruleStatus; + synchronized (this) { + ruleStatus = getRuleStatus(ruleUID); + } + logger.error("Failed to execute rule '{}' with status '{}': could not acquire rule thread", ruleUID, + ruleStatus == null ? "UNKNOWN" : ruleStatus.name()); + return CompletableFuture.failedFuture(new IllegalStateException( + "Could not acquire rule thread for rule '" + ruleUID + "' for rule execution")); + } + + Future> future; + try { + future = thCallback.getScheduler().submit(new RunRuleCallable(rule, considerConditions, context)); + } catch (RejectedExecutionException e) { + logger.warn("Failed to execute rule '{}' because the rule executor rejected execution: {}", ruleUID, + e.getMessage()); + return CompletableFuture.failedFuture(e); + } + return future; + } + /** * Clears all dynamic parameters from the {@link Rule}'s context. * @@ -1680,11 +1690,26 @@ private void compileRules() { private void executeRulesWithStartLevel() { getScheduledExecutor().submit(() -> { - ruleRegistry.stream() // + List>> futures = ruleRegistry.stream() // .filter(this::mustTrigger) // - .forEach(r -> runNow(r.getUID(), true, + .map(r -> runAsync(r.getUID(), true, Map.of(SystemTriggerHandler.OUT_STARTLEVEL, StartLevelService.STARTLEVEL_RULES, "event", - SystemEventFactory.createStartlevelEvent(StartLevelService.STARTLEVEL_RULES)))); + SystemEventFactory.createStartlevelEvent(StartLevelService.STARTLEVEL_RULES)))) + .toList(); + + // Wait for the rule executions to complete before announcing to be "started" + for (Future> future : futures) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while executing rules with startlevels"); + return; + } catch (ExecutionException e) { + logger.warn("Failed to execute rule at startlevel: {}", e.getMessage()); + logger.trace("", e); + } + } started = true; readyService.markReady(MARKER); logger.info("Rule engine started."); @@ -1755,4 +1780,55 @@ public boolean isStarted() { public Stream simulateRuleExecutions(ZonedDateTime from, ZonedDateTime until) { return new RuleExecutionSimulator(this.ruleRegistry, this).simulateRuleExecutions(from, until); } + + private class RunRuleCallable implements Callable> { + + private final WrappedRule rule; + private final boolean considerConditions; + private final @Nullable Map context; + + public RunRuleCallable(WrappedRule rule, boolean considerConditions, + @Nullable Map context) { + this.rule = rule; + this.considerConditions = considerConditions; + this.context = context; + } + + @Override + public Map call() throws Exception { + String ruleUID = rule.getUID(); + Map returnContext = new HashMap<>(); + synchronized (RuleEngineImpl.this) { + final RuleStatus ruleStatus = getRuleStatus(ruleUID); + if (ruleStatus != null && ruleStatus != RuleStatus.IDLE) { + logger.error("Failed to execute rule '{}' with status '{}'", ruleUID, ruleStatus.name()); + return Map.of(); + } + // change state to RUNNING + setStatus(ruleUID, new RuleStatusInfo(RuleStatus.RUNNING)); + } + try { + clearContext(ruleUID); + Map context = this.context; + if (context != null && !context.isEmpty()) { + getContext(ruleUID, null).putAll(context); + } + if (!considerConditions || calculateConditions(rule)) { + executeActions(rule, false); + } + logger.debug("The rule '{}' was executed.", ruleUID); + returnContext.putAll(getContext(ruleUID, null)); + } catch (Throwable t) { + logger.error("Failed to execute rule '{}': ", ruleUID, t); + } finally { + // change state to IDLE only if the rule has not been DISABLED. + synchronized (RuleEngineImpl.this) { + if (getRuleStatus(ruleUID) == RuleStatus.RUNNING) { + setStatus(ruleUID, new RuleStatusInfo(RuleStatus.IDLE)); + } + } + } + return returnContext; + } + } } From ab88a12379e466e4831aa6098b5c830621267f8d Mon Sep 17 00:00:00 2001 From: Ravi Nadahar Date: Mon, 8 Jun 2026 22:45:21 +0200 Subject: [PATCH 2/3] Make sure that the context lock is locked for script engines that require it when loading scripts Signed-off-by: Ravi Nadahar --- .../internal/ScriptEngineManagerImpl.java | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java index f2cff2ca0c7..2b321013aa7 100644 --- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; import javax.script.Invocable; import javax.script.ScriptContext; @@ -55,6 +56,7 @@ @Component(service = ScriptEngineManager.class) public class ScriptEngineManagerImpl implements ScriptEngineManager { + private static final long LOCK_ACQUISITION_TIMEOUT_S = 60L; private final ScheduledExecutorService scheduler = ThreadPoolManager .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON); @@ -159,32 +161,61 @@ public boolean loadScript(String engineIdentifier, InputStreamReader scriptData) ScriptEngineContainer container = loadedScriptEngineInstances.get(engineIdentifier); if (container == null) { logger.error("Could not load script, as no ScriptEngine has been created"); - } else { - ScriptEngine engine = container.getScriptEngine(); + return false; + } + ScriptEngine engine = container.getScriptEngine(); + if (engine instanceof Lock lock) { + boolean locked; try { - engine.eval(scriptData); - if (engine instanceof Invocable inv) { - try { - inv.invokeFunction("scriptLoaded", engineIdentifier); - } catch (NoSuchMethodException e) { - logger.trace("scriptLoaded() is not defined in the script: {}", engineIdentifier); - } - } else { - logger.trace("ScriptEngine does not support Invocable interface"); + locked = lock.tryLock(LOCK_ACQUISITION_TIMEOUT_S, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Interrupted while waiting to acquire the lock while loading script for engine '{}'", + engineIdentifier); + logger.trace("", e); + return false; + } + if (locked) { + try { + return runScript(engineIdentifier, engine, scriptData); + } finally { + lock.unlock(); } - return true; - } catch (Exception ex) { - logger.error("Error during evaluation of script '{}': {}", engineIdentifier, ex.getMessage()); - // Only call logger if debug level is actually enabled, because OPS4J Pax Logging holds (at least for - // some time) a reference to the exception and its cause, which may hold a reference to the script - // engine. - // This prevents garbage collection (at least for some time) to remove the script engine from heap. - if (logger.isDebugEnabled()) { - logger.debug("", ex); + } else { + logger.error( + "Failed to acquire the lock while loading script for engine '{}' within {} seconds. Aborting loading of script.", + engineIdentifier, LOCK_ACQUISITION_TIMEOUT_S); + return false; + } + } else { + return runScript(engineIdentifier, engine, scriptData); + } + } + + private boolean runScript(String engineIdentifier, ScriptEngine engine, InputStreamReader scriptData) { + try { + engine.eval(scriptData); + if (engine instanceof Invocable inv) { + try { + inv.invokeFunction("scriptLoaded", engineIdentifier); + } catch (NoSuchMethodException e) { + logger.trace("scriptLoaded() is not defined in the script: {}", engineIdentifier); } + } else { + logger.trace("ScriptEngine does not support Invocable interface"); + } + return true; + } catch (Exception ex) { + logger.error("Error during evaluation of script '{}': {}", engineIdentifier, ex.getMessage()); + // Only call logger if debug level is actually enabled, because OPS4J Pax Logging holds (at least for + // some time) a reference to the exception and its cause, which may hold a reference to the script + // engine. + // This prevents garbage collection (at least for some time) to remove the script engine from heap. + if (logger.isDebugEnabled()) { + logger.debug("", ex); } + return false; } - return false; } @Override From 63ae078fe44fe00d15e54b2be5499785ade03757 Mon Sep 17 00:00:00 2001 From: Ravi Nadahar Date: Tue, 9 Jun 2026 03:22:50 +0200 Subject: [PATCH 3/3] Add the asynchronous run methods to ModuleHandlerCallback Signed-off-by: Ravi Nadahar --- .../SimpleTriggerHandlerCallbackDelegate.java | 12 +++++++ .../automation/ModuleHandlerCallback.java | 34 +++++++++++++++++++ .../automation/internal/RuleEngineImpl.java | 11 ++++++ .../internal/TriggerHandlerCallbackImpl.java | 11 ++++++ .../composite/CompositeTriggerHandler.java | 12 +++++++ 5 files changed, 80 insertions(+) diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/delegates/SimpleTriggerHandlerCallbackDelegate.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/delegates/SimpleTriggerHandlerCallbackDelegate.java index fdd643cc0da..ea7bdd1382e 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/delegates/SimpleTriggerHandlerCallbackDelegate.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/delegates/SimpleTriggerHandlerCallbackDelegate.java @@ -13,6 +13,7 @@ package org.openhab.core.automation.module.script.rulesupport.internal.delegates; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -82,4 +83,15 @@ public void runNow(String uid) { public void runNow(String uid, boolean considerConditions, @Nullable Map context) { callback.runNow(uid, considerConditions, context); } + + @Override + public Future> runAsync(String ruleUID) { + return callback.runAsync(ruleUID); + } + + @Override + public Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + return callback.runAsync(ruleUID, considerConditions, context); + } } diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/ModuleHandlerCallback.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/ModuleHandlerCallback.java index c0d7f882995..bb58a5fa228 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/ModuleHandlerCallback.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/ModuleHandlerCallback.java @@ -13,6 +13,7 @@ package org.openhab.core.automation; import java.util.Map; +import java.util.concurrent.Future; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -92,4 +93,37 @@ public interface ModuleHandlerCallback { * @param context the context that is passed to the conditions and the actions of the rule. */ void runNow(String uid, boolean considerConditions, @Nullable Map context); + + /** + * The method skips triggers and conditions and executes the actions of the rule asynchronously. + * This should always be possible unless an action has a mandatory input that is linked to a trigger. + * In that case the action is skipped and the rule engine continues execution of remaining actions. + *

+ * Note: Unlike {@link #runNow(String)}, this method will return immediately. To wait for the execution to be + * completed, call {@link Future#get()} on the returned {@link Future}. + * + * @param ruleUID uid of the rule whose actions should be executed. + * @return A {@link Future} containing the copy of the rule context after completion, including possible return + * values. + * @throws UnsupportedOperationException If asynchronous execution isn't supported by the {@link RuleManager} + * implementation. + */ + Future> runAsync(String ruleUID); + + /** + * Same as {@link #runAsync(String)} with the additional option to enable/disable evaluation of + * conditions defined in the target rule. The context can be set here, too, but might also be {@code null}. + *

+ * Note: Unlike {@link #runNow(String, boolean, Map)}, this method will return immediately. To wait for the + * execution to be completed, call {@link Future#get()} on the returned {@link Future}. + * + * @param ruleUID uid of the rule whose actions should be executed. + * @param considerConditions if {@code true} the conditions of the rule will be checked. + * @param context the context that is passed to the conditions and the actions of the rule. + * @return a copy of the rule context, including possible return values + * @throws UnsupportedOperationException If asynchronous execution isn't supported by the {@link RuleManager} + * implementation. + */ + Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context); } diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java index c0e95201d4d..fd61c0ce738 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java @@ -264,6 +264,17 @@ public void runNow(String uid) { public void runNow(String uid, boolean considerConditions, @Nullable Map context) { RuleEngineImpl.this.runNow(uid, considerConditions, context); } + + @Override + public Future> runAsync(String ruleUID) { + return RuleEngineImpl.this.runAsync(ruleUID); + } + + @Override + public Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + return RuleEngineImpl.this.runAsync(ruleUID, considerConditions, context); + } }; /** diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/TriggerHandlerCallbackImpl.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/TriggerHandlerCallbackImpl.java index c4bb0da2f3d..43da257a1b6 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/TriggerHandlerCallbackImpl.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/TriggerHandlerCallbackImpl.java @@ -123,6 +123,17 @@ public void runNow(String uid, boolean considerConditions, @Nullable Map> runAsync(String ruleUID) { + return re.runAsync(ruleUID); + } + + @Override + public Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + return re.runAsync(ruleUID, considerConditions, context); + } + @Override public ScheduledExecutorService getScheduler() { return executor; diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/composite/CompositeTriggerHandler.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/composite/CompositeTriggerHandler.java index d96cae0d6ac..114e03f5b2f 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/composite/CompositeTriggerHandler.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/composite/CompositeTriggerHandler.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.StringTokenizer; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -174,4 +175,15 @@ public void runNow(String uid) { public void runNow(String uid, boolean considerConditions, @Nullable Map context) { callback.runNow(uid, considerConditions, context); } + + @Override + public Future> runAsync(String ruleUID) { + return callback.runAsync(ruleUID); + } + + @Override + public Future> runAsync(String ruleUID, boolean considerConditions, + @Nullable Map context) { + return callback.runAsync(ruleUID, considerConditions, context); + } }