From e794d429244fea1d26b042f8e6134671bad5b403 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 31 Oct 2025 15:11:24 +0100 Subject: [PATCH 01/11] Graceful aborting --- .../core/code/ExecutionAbortException.java | 13 +++++++++++ .../es/acm/core/code/ExecutionContext.java | 15 ++++++++++++ .../vml/es/acm/core/code/ExecutionQueue.java | 23 +++++++++++++++---- .../vml/es/acm/core/util/ExceptionUtils.java | 4 ++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/ExecutionAbortException.java diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionAbortException.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionAbortException.java new file mode 100644 index 00000000..89b2165a --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionAbortException.java @@ -0,0 +1,13 @@ +package dev.vml.es.acm.core.code; + + +public class ExecutionAbortException extends RuntimeException { + + public ExecutionAbortException(String message) { + super(message); + } + + public ExecutionAbortException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java index 97a2c33d..99427cbf 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java @@ -151,6 +151,20 @@ public void setSkipped(boolean skipped) { this.skipped = skipped; } + public boolean isAborted() { + return getCodeContext().getOsgiContext() + .getService(ExecutionQueue.class) + .findByExecutableId(id) + .map(e -> e.getStatus() == ExecutionStatus.ABORTED) + .orElse(false); + } + + public void checkAborted() throws ExecutionAbortException { + if (isAborted()) { + throw new ExecutionAbortException("Execution has been aborted gracefully!"); + } + } + public Inputs getInputs() { return inputs; } @@ -174,6 +188,7 @@ public Conditions getConditions() { private void customizeBinding() { Binding binding = getCodeContext().getBinding(); + binding.setVariable("context", this); binding.setVariable("schedules", schedules); binding.setVariable("arguments", inputs); // TODO deprecated binding.setVariable("inputs", inputs); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java index 90732afa..0bc0b4a5 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java @@ -52,7 +52,7 @@ public class ExecutionQueue implements JobExecutor, EventListener { @AttributeDefinition( name = "Async Poll Interval", description = "Interval in milliseconds to poll for job status.") - long asyncPollInterval() default 500L; + long asyncPollInterval() default 750L; } @Reference @@ -209,9 +209,13 @@ public JobExecutionResult process(Job job, JobExecutionContext context) { }); while (!future.isDone()) { - if (context.isStopped() || Thread.currentThread().isInterrupted()) { - future.cancel(true); - LOG.debug("Execution is cancelling '{}'", queuedExecution); + if (context.isStopped()) { + if (Thread.currentThread().isInterrupted()) { + LOG.debug("Execution is aborting forcefully '{}'", queuedExecution); + future.cancel(true); + } else { + LOG.debug("Execution is aborting gracefully '{}'", queuedExecution); + } break; } try { @@ -236,12 +240,21 @@ public JobExecutionResult process(Job job, JobExecutionContext context) { return context.result().succeeded(); } } catch (CancellationException e) { - LOG.warn("Execution aborted '{}'", queuedExecution); + LOG.warn("Execution aborted forcefully '{}'", queuedExecution); return context.result() .message(QueuedMessage.of(ExecutionStatus.ABORTED, ExceptionUtils.toString(e)) .toJson()) .cancelled(); } catch (Exception e) { + Throwable cause = ExceptionUtils.getRootCause(e); + if (cause instanceof ExecutionAbortException) { + LOG.warn("Execution aborted gracefully '{}'", queuedExecution); + return context.result() + .message(QueuedMessage.of(ExecutionStatus.ABORTED, ExceptionUtils.toString(cause)) + .toJson()) + .cancelled(); + } + LOG.error("Execution failed '{}'", queuedExecution, e); return context.result() .message(QueuedMessage.of(ExecutionStatus.FAILED, ExceptionUtils.toString(e)) diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ExceptionUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/ExceptionUtils.java index e6fff9f9..c90105c4 100644 --- a/core/src/main/java/dev/vml/es/acm/core/util/ExceptionUtils.java +++ b/core/src/main/java/dev/vml/es/acm/core/util/ExceptionUtils.java @@ -13,4 +13,8 @@ public static String toString(Throwable cause) { .map(org.apache.commons.lang3.exception.ExceptionUtils::getStackTrace) .orElse(null); } + + public static Throwable getRootCause(Throwable throwable) { + return org.apache.commons.lang3.exception.ExceptionUtils.getRootCause(throwable); + } } From 7db75b0effc4e524dbca9fa2c2ec965c332c3113 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 31 Oct 2025 15:16:32 +0100 Subject: [PATCH 02/11] Check aborted --- .../settings/snippet/available/core/general/demo_processing.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml index 1e2e4f4d..8c8036e9 100644 --- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml +++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml @@ -11,6 +11,7 @@ content: | println "Updating resources..." def max = 20 for (int i = 0; i < max; i++) { + context.checkAborted() Thread.sleep(1000) println "Updated (\${i + 1}/\${max})" } From 31dc37a8204b9d54cdfc521c814238961a0f505f Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 31 Oct 2025 15:37:42 +0100 Subject: [PATCH 03/11] Graceful abort without timeout --- .../vml/es/acm/core/code/ExecutionQueue.java | 29 +++++++++++++++---- .../src/components/ExecutionAbortButton.tsx | 3 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java index 0bc0b4a5..59dc1ebd 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java @@ -53,6 +53,11 @@ public class ExecutionQueue implements JobExecutor, EventListener { name = "Async Poll Interval", description = "Interval in milliseconds to poll for job status.") long asyncPollInterval() default 750L; + + @AttributeDefinition( + name = "Abort Timeout", + description = "Time in milliseconds to wait for graceful abort before forcing it.") + long abortTimeout() default -1; } @Reference @@ -208,16 +213,28 @@ public JobExecutionResult process(Job job, JobExecutionContext context) { } }); + Long abortStartTime = null; while (!future.isDone()) { if (context.isStopped()) { - if (Thread.currentThread().isInterrupted()) { - LOG.debug("Execution is aborting forcefully '{}'", queuedExecution); - future.cancel(true); - } else { - LOG.debug("Execution is aborting gracefully '{}'", queuedExecution); + if (abortStartTime == null) { + abortStartTime = System.currentTimeMillis(); + if (config.abortTimeout() < 0) { + LOG.info("Execution is aborting gracefully '{}' (no timeout)", queuedExecution); + } else { + LOG.info("Execution is aborting '{}' (timeout: {}ms)", + queuedExecution, config.abortTimeout()); + } + } else if (config.abortTimeout() >= 0) { + long abortDuration = System.currentTimeMillis() - abortStartTime; + if (abortDuration >= config.abortTimeout()) { + LOG.error("Execution abort timeout exceeded ({}ms), forcing abort '{}'", + abortDuration, queuedExecution); + future.cancel(true); + break; + } } - break; } + try { Thread.sleep(config.asyncPollInterval()); } catch (InterruptedException e) { diff --git a/ui.frontend/src/components/ExecutionAbortButton.tsx b/ui.frontend/src/components/ExecutionAbortButton.tsx index 6eb409bf..d156501f 100644 --- a/ui.frontend/src/components/ExecutionAbortButton.tsx +++ b/ui.frontend/src/components/ExecutionAbortButton.tsx @@ -79,7 +79,8 @@ const ExecutionAbortButton: React.FC = ({ execution,

This action will abort current code execution.

-

Be aware that aborting execution may leave data in an inconsistent state.

+

Ensure that for long-running executions, context.checkAborted() is called regularly to allow for graceful termination.

+

If graceful termination is not possible and timed out, the execution will be forcefully terminated. This may result in data loss or corruption sometimes making the instance unusable.