+#### Locking
+
+While a script runs, ACM holds a repository lock scoped to that script, so the same script cannot run twice at the same time (e.g. a scheduled run starting before the previous one finished, two cluster nodes firing together, or the script triggered manually twice). A second attempt while the lock is held ends with the `LOCKED` status instead of running the code again.
+
+To avoid a lock being held forever when an instance is killed abruptly (e.g. pod recycling, `kill -9`, a crash) — leaving a *stale lock* that would block every future run — the lock carries an expiration time. Once it expires, the next run recreates it and proceeds normally. The default expiration comes from the global `Lock Timeout` OSGi configuration of the *Code Executor* (24 hours).
+
+A single global value rarely fits every script, so it can be overridden per script by setting `context.lockTimeout` in `describeRun()`. This is handy when:
+
+- **The script runs longer than the global default** — the timeout must always exceed the maximum expected execution time, otherwise the lock could expire mid-run and let a second run start concurrently. Long-running migrations or reports should raise it.
+- **The script is short and should recover fast** — lowering it means a stale lock left by a crash is cleared sooner, so the script becomes runnable again quickly.
+- **Expiration should be disabled entirely** — for a critical singleton where accidental concurrency is worse than a stuck lock, set `0` or a negative value (the lock then only clears on a normal finish or a manual reset).
+
+```groovy
+import java.time.Duration
+
+void describeRun() {
+ context.lockTimeout = "2h" // human-readable: 'ms', 's', 'm', 'h', 'd' (e.g. '2h30m', '500ms')
+ // context.lockTimeout = "PT2H30M" // ISO-8601 duration
+ // context.lockTimeout = Duration.ofHours(2)
+ // context.lockTimeout = 7_200_000 // milliseconds (0 or negative disables lock expiration)
+}
+
+boolean canRun() {
+ return conditions.always()
+}
+
+void doRun() {
+ // long-running work protected by the extended lock timeout
+}
+```
+
+The effective timeout is resolved by precedence: value set in the script's `describeRun()` → value set by an extension's `prepareRun()` → global OSGi `Lock Timeout` configuration.
### History
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 c4a1fd7fc..7a19538a8 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
@@ -3,8 +3,10 @@
import dev.vml.es.acm.core.AcmConstants;
import dev.vml.es.acm.core.code.log.LogInterceptorManager;
import dev.vml.es.acm.core.repo.Repo;
+import dev.vml.es.acm.core.util.DurationUtils;
import dev.vml.es.acm.core.util.ResolverUtils;
import groovy.lang.Binding;
+import java.time.Duration;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ResourceResolverFactory;
@@ -42,6 +44,8 @@ public static String varPath(String executionId) {
private boolean skipped = false;
+ private Duration lockTimeout = null;
+
private final Inputs inputs;
private InputValues inputValues;
@@ -171,6 +175,14 @@ public void setSkipped(boolean skipped) {
this.skipped = skipped;
}
+ public Duration getLockTimeout() {
+ return lockTimeout;
+ }
+
+ public void setLockTimeout(Object lockTimeout) {
+ this.lockTimeout = DurationUtils.toDuration(lockTimeout);
+ }
+
public boolean isAborted() {
return getCodeContext()
.getOsgiContext()
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 10ca5b090..6de938c9e 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
@@ -277,7 +277,12 @@ public JobExecutionResult process(Job job, JobExecutionContext context) {
try {
Execution immediateExecution = future.get();
- if (immediateExecution.getStatus() == ExecutionStatus.SKIPPED) {
+ if (immediateExecution.getStatus() == ExecutionStatus.LOCKED) {
+ LOG.debug("Execution locked '{}'", immediateExecution);
+ return context.result()
+ .message(QueuedMessage.of(ExecutionStatus.LOCKED, null).toJson())
+ .cancelled();
+ } else if (immediateExecution.getStatus() == ExecutionStatus.SKIPPED) {
LOG.debug("Execution skipped '{}'", immediateExecution);
return context.result()
.message(QueuedMessage.of(ExecutionStatus.SKIPPED, null).toJson())
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionStatus.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionStatus.java
index 46707d1c8..496b25d75 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutionStatus.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutionStatus.java
@@ -17,6 +17,7 @@ public enum ExecutionStatus {
STOPPING,
// queued & immediate execution statuses
SKIPPED,
+ LOCKED,
STOPPED,
ABORTED,
FAILED,
@@ -66,4 +67,12 @@ public static ExecutionStatus of(Job job, Executor executor) {
public static List
+ locked. Locks expire automatically (configurable via the code executor
+ settings) to recover from instances killed abruptly, e.g. pod recycling.
+