Skip to content

Commit 9f9abea

Browse files
Merge pull request #255 from wttech/graceful-abort
Graceful aborting
2 parents d7cca7e + cd5486e commit 9f9abea

8 files changed

Lines changed: 190 additions & 39 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ It works seamlessly across AEM on-premise, AMS, and AEMaaCS environments.
5959
- [Outputs example](#outputs-example)
6060
- [ACL example](#acl-example)
6161
- [Repo example](#repo-example)
62+
- [Abortable example](#abortable-example)
6263
- [History](#history)
6364
- [Extension scripts](#extension-scripts)
6465
- [Example extension script](#example-extension-script)
@@ -421,6 +422,38 @@ void doRun() {
421422

422423
<img src="docs/screenshot-content-script-repo-output.png" width="720" alt="ACM ACL Repo Output">
423424

425+
#### Abortable example
426+
427+
For long-running scripts that process many nodes, it's important to support graceful abortion. This allows users to stop the script execution without leaving the repository in an inconsistent state.
428+
429+
```groovy
430+
void doRun() {
431+
repo.queryRaw("SELECT * FROM [nt:base] WHERE ISDESCENDANTNODE('/content/acme/us/en')").forEach { resource ->
432+
// Safe point
433+
context.checkAborted()
434+
435+
// Process resource
436+
// TODO resource.save() etc.
437+
}
438+
}
439+
```
440+
441+
Alternatively, you can use `context.isAborted()` for manual control:
442+
443+
```groovy
444+
void doRun() {
445+
def assets = repo.queryRaw("SELECT * FROM [dam:Asset] WHERE ISDESCENDANTNODE('/content/dam')").iterator()
446+
for (asset in assets) {
447+
if (context.isAborted()) {
448+
// Do clean when aborted
449+
break
450+
}
451+
}
452+
// Still remember to propagate abort status
453+
context.checkAborted()
454+
}
455+
```
456+
424457
### History
425458

426459
All code executions are logged in the history. You can see the status of each execution, including whether it was successful or failed. The history also provides detailed logs for each execution, including any errors that occurred.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package dev.vml.es.acm.core.code;
2+
3+
public class AbortException extends RuntimeException {
4+
5+
public AbortException(String message) {
6+
super(message);
7+
}
8+
9+
public AbortException(String message, Throwable cause) {
10+
super(message, cause);
11+
}
12+
}

core/src/main/java/dev/vml/es/acm/core/code/ExecutionContext.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ public void setSkipped(boolean skipped) {
151151
this.skipped = skipped;
152152
}
153153

154+
public boolean isAborted() {
155+
return getCodeContext()
156+
.getOsgiContext()
157+
.getService(ExecutionQueue.class)
158+
.isAborted(getId());
159+
}
160+
161+
public void abort() {
162+
throw new AbortException("Execution aborted gracefully!");
163+
}
164+
165+
public void checkAborted() throws AbortException {
166+
if (isAborted()) {
167+
abort();
168+
}
169+
}
170+
154171
public Inputs getInputs() {
155172
return inputs;
156173
}
@@ -174,6 +191,7 @@ public Conditions getConditions() {
174191
private void customizeBinding() {
175192
Binding binding = getCodeContext().getBinding();
176193

194+
binding.setVariable("context", this);
177195
binding.setVariable("schedules", schedules);
178196
binding.setVariable("arguments", inputs); // TODO deprecated
179197
binding.setVariable("inputs", inputs);

core/src/main/java/dev/vml/es/acm/core/code/ExecutionQueue.java

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import dev.vml.es.acm.core.util.StreamUtils;
1111
import java.util.*;
1212
import java.util.concurrent.CancellationException;
13+
import java.util.concurrent.ConcurrentHashMap;
1314
import java.util.concurrent.ExecutorService;
1415
import java.util.concurrent.Executors;
1516
import java.util.concurrent.Future;
@@ -52,7 +53,12 @@ public class ExecutionQueue implements JobExecutor, EventListener {
5253
@AttributeDefinition(
5354
name = "Async Poll Interval",
5455
description = "Interval in milliseconds to poll for job status.")
55-
long asyncPollInterval() default 500L;
56+
long asyncPollInterval() default 750L;
57+
58+
@AttributeDefinition(
59+
name = "Abort Timeout",
60+
description = "Time in milliseconds to wait for graceful abort before forcing it.")
61+
long abortTimeout() default -1;
5662
}
5763

5864
@Reference
@@ -69,6 +75,8 @@ public class ExecutionQueue implements JobExecutor, EventListener {
6975

7076
private ExecutorService jobAsyncExecutor;
7177

78+
private final Map<String, Boolean> jobAborted = new ConcurrentHashMap<>();
79+
7280
private Config config;
7381

7482
@Activate
@@ -128,6 +136,10 @@ public Optional<Execution> findByExecutableId(String executableId) {
128136
.findFirst();
129137
}
130138

139+
public boolean isAborted(String executionId) {
140+
return Boolean.TRUE.equals(jobAborted.get(executionId));
141+
}
142+
131143
public Stream<ExecutionSummary> findAllSummaries() {
132144
return findJobs().map(job -> new QueuedExecutionSummary(executor, job));
133145
}
@@ -208,46 +220,93 @@ public JobExecutionResult process(Job job, JobExecutionContext context) {
208220
}
209221
});
210222

211-
while (!future.isDone()) {
212-
if (context.isStopped() || Thread.currentThread().isInterrupted()) {
213-
future.cancel(true);
214-
LOG.debug("Execution is cancelling '{}'", queuedExecution);
215-
break;
223+
try {
224+
Long abortStartTime = null;
225+
while (!future.isDone()) {
226+
if (context.isStopped()) {
227+
if (abortStartTime == null) {
228+
abortStartTime = System.currentTimeMillis();
229+
jobAborted.put(job.getId(), Boolean.TRUE);
230+
231+
if (config.abortTimeout() < 0) {
232+
LOG.debug("Execution is aborting gracefully '{}' (no timeout)", queuedExecution);
233+
} else {
234+
LOG.debug(
235+
"Execution is aborting '{}' (timeout: {}ms)",
236+
queuedExecution,
237+
config.abortTimeout());
238+
}
239+
} else if (config.abortTimeout() >= 0) {
240+
long abortDuration = System.currentTimeMillis() - abortStartTime;
241+
if (abortDuration >= config.abortTimeout()) {
242+
LOG.debug(
243+
"Execution abort timeout exceeded ({}ms), forcing abort '{}'",
244+
abortDuration,
245+
queuedExecution);
246+
future.cancel(true);
247+
break;
248+
}
249+
}
250+
}
251+
252+
try {
253+
Thread.sleep(config.asyncPollInterval());
254+
} catch (InterruptedException e) {
255+
Thread.currentThread().interrupt();
256+
LOG.debug("Execution is interrupted '{}'", queuedExecution);
257+
return context.result().cancelled();
258+
}
216259
}
260+
217261
try {
218-
Thread.sleep(config.asyncPollInterval());
219-
} catch (InterruptedException e) {
220-
Thread.currentThread().interrupt();
221-
LOG.debug("Execution is interrupted '{}'", queuedExecution);
222-
return context.result().cancelled();
262+
Execution immediateExecution = future.get();
263+
264+
if (immediateExecution.getStatus() == ExecutionStatus.SKIPPED) {
265+
LOG.debug("Execution skipped '{}'", immediateExecution);
266+
return context.result()
267+
.message(QueuedMessage.of(ExecutionStatus.SKIPPED, null)
268+
.toJson())
269+
.cancelled();
270+
} else {
271+
LOG.debug("Execution succeeded '{}'", immediateExecution);
272+
return context.result().succeeded();
273+
}
274+
} catch (CancellationException e) {
275+
LOG.debug("Execution aborted forcefully '{}'", queuedExecution);
276+
return context.result()
277+
.message(QueuedMessage.of(ExecutionStatus.ABORTED, ExceptionUtils.toString(e))
278+
.toJson())
279+
.cancelled();
280+
} catch (Exception e) {
281+
AbortException abortException = findAbortException(e);
282+
if (abortException != null) {
283+
LOG.debug("Execution aborted gracefully '{}'", queuedExecution);
284+
return context.result()
285+
.message(QueuedMessage.of(ExecutionStatus.ABORTED, ExceptionUtils.toString(abortException))
286+
.toJson())
287+
.cancelled();
288+
}
289+
290+
LOG.debug("Execution failed '{}'", queuedExecution, e);
291+
return context.result()
292+
.message(QueuedMessage.of(ExecutionStatus.FAILED, ExceptionUtils.toString(e))
293+
.toJson())
294+
.failed();
223295
}
296+
} finally {
297+
jobAborted.remove(job.getId());
224298
}
299+
}
225300

226-
try {
227-
Execution immediateExecution = future.get();
228-
229-
if (immediateExecution.getStatus() == ExecutionStatus.SKIPPED) {
230-
LOG.debug("Execution skipped '{}'", immediateExecution);
231-
return context.result()
232-
.message(QueuedMessage.of(ExecutionStatus.SKIPPED, null).toJson())
233-
.cancelled();
234-
} else {
235-
LOG.info("Execution succeeded '{}'", immediateExecution);
236-
return context.result().succeeded();
301+
private AbortException findAbortException(Throwable e) {
302+
Throwable current = e;
303+
while (current != null) {
304+
if (current instanceof AbortException) {
305+
return (AbortException) current;
237306
}
238-
} catch (CancellationException e) {
239-
LOG.warn("Execution aborted '{}'", queuedExecution);
240-
return context.result()
241-
.message(QueuedMessage.of(ExecutionStatus.ABORTED, ExceptionUtils.toString(e))
242-
.toJson())
243-
.cancelled();
244-
} catch (Exception e) {
245-
LOG.error("Execution failed '{}'", queuedExecution, e);
246-
return context.result()
247-
.message(QueuedMessage.of(ExecutionStatus.FAILED, ExceptionUtils.toString(e))
248-
.toJson())
249-
.failed();
307+
current = current.getCause();
250308
}
309+
return null;
251310
}
252311

253312
private Execution executeAsync(ExecutionContextOptions contextOptions, QueuedExecution execution)

core/src/main/java/dev/vml/es/acm/core/code/Executor.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,18 +230,28 @@ private ContextualExecution executeInternal(ExecutionContext context) {
230230
context.getOut().withLoggerTimestamps(config.logPrintingTimestamps());
231231
}
232232
contentScript.run();
233+
234+
LOG.info("Execution succeeded '{}'", context.getId());
233235
return execution.end(ExecutionStatus.SUCCEEDED);
234236
} finally {
235237
if (locking) {
236238
useLocker(resolverFactory, l -> l.unlock(lockName));
237239
}
238240
}
239-
} catch (Throwable e) {
241+
} catch (AbortException e) {
242+
LOG.warn("Execution aborted gracefully '{}'", context.getId());
240243
execution.error(e);
244+
return execution.end(ExecutionStatus.ABORTED);
245+
} catch (Throwable e) {
241246
if ((e.getCause() != null && e.getCause() instanceof InterruptedException)) {
247+
LOG.warn("Execution aborted forcefully '{}'", context.getId());
248+
execution.error(e);
242249
return execution.end(ExecutionStatus.ABORTED);
250+
} else {
251+
LOG.error("Execution failed '{}'", context.getId(), e);
252+
execution.error(e);
253+
return execution.end(ExecutionStatus.FAILED);
243254
}
244-
return execution.end(ExecutionStatus.FAILED);
245255
} finally {
246256
statuses.remove(context.getId());
247257
}

core/src/main/java/dev/vml/es/acm/core/util/ExceptionUtils.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ public static String toString(Throwable cause) {
1313
.map(org.apache.commons.lang3.exception.ExceptionUtils::getStackTrace)
1414
.orElse(null);
1515
}
16+
1617
}

ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/general/demo_processing.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ content: |
1111
println "Updating resources..."
1212
def max = 20
1313
for (int i = 0; i < max; i++) {
14+
context.checkAborted()
1415
Thread.sleep(1000)
1516
println "Updated (\${i + 1}/\${max})"
1617
}

ui.frontend/src/components/ExecutionAbortButton.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, Text } from '@adobe/react-spectrum';
1+
import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, InlineAlert, Text } from '@adobe/react-spectrum';
22
import { ToastQueue } from '@react-spectrum/toast';
3+
import AlertIcon from '@spectrum-icons/workflow/Alert';
34
import Cancel from '@spectrum-icons/workflow/Cancel';
5+
import CheckmarkCircle from '@spectrum-icons/workflow/CheckmarkCircle';
6+
import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
47
import StopCircle from '@spectrum-icons/workflow/StopCircle';
58
import React, { useState } from 'react';
69
import { useDeepCompareEffect } from 'react-use';
@@ -78,8 +81,22 @@ const ExecutionAbortButton: React.FC<ExecutionAbortButtonProps> = ({ execution,
7881
</Heading>
7982
<Divider />
8083
<Content>
81-
<p>This action will abort current code execution.</p>
82-
<p>Be aware that aborting execution may leave data in an inconsistent state.</p>
84+
<p>
85+
<CheckmarkCircle size="XS" /> The abort request signals the script to stop, but the script must explicitly check for this signal by calling <code>context.checkAborted()</code>.
86+
</p>
87+
<p>
88+
<CloseCircle size="XS" /> If the script doesn't check for abort, it will continue running until it completes naturally. Only if an abort timeout is configured (by default it's not), will the execution be forcefully terminated after the timeout expires.
89+
</p>
90+
<p>
91+
<AlertIcon size="XS" /> For scripts with loops or long-running operations, add <code>context.checkAborted()</code> at safe checkpoints (e.g., at the beginning of each loop iteration) to enable graceful termination and prevent data corruption.
92+
</p>
93+
94+
<InlineAlert width="100%" variant="negative" UNSAFE_style={{ padding: '8px' }} marginTop="size-200">
95+
<Heading>Warning</Heading>
96+
<Content>
97+
Proceed with aborting only if the requirements above are met.
98+
</Content>
99+
</InlineAlert>
83100
</Content>
84101
<ButtonGroup>
85102
<Button variant="secondary" onPress={() => setShowDialog(false)} isDisabled={isAborting}>

0 commit comments

Comments
 (0)