How to stop parallel rule execution in Oracle EPM Cloud
A user launches a forecast allocation. Nothing visible happens for twenty seconds, so they click Run again. Two copies of the same rule are now working across the same intersections at the same time.
Sometimes it’s one impatient user. More often it’s two people in different offices who both own the process and neither knows the other has started it. Either way the rule is working exactly as written. Parallel rule execution is what does the damage: two runs competing for the same data and the same intersections, with the outcome decided by whichever finishes last.
Oracle EPM Cloud won’t stop this on its own. The Job Console records both executions afterwards, which helps you explain what happened and does nothing to prevent it.
This is a write-up of a framework I built to close that gap: a lock that lives inside the application, recovers itself when an execution is abandoned, and can be dropped into any Groovy business rule. The full template is at the end.
The constraint I was working inside
This started as a self-set problem rather than a client requirement. It came out of building a set of long-running Planning rules and noticing how exposed they were. The goal was easy to state: stop the same business rule running more than once at a time. The conditions I put on it were what made the design interesting.
No new infrastructure. No external database holding lock records, because that means a server, a connection, credentials, a patching cycle, and one more thing that can be down when the close is running. No administrator in the loop, because a control that depends on someone being at their desk is not a control. And it had to recover on its own, since sessions expire and connections drop in the middle of long calculations more often than anyone plans for.
Why substitution variables
Four options were on the table. Three of them lost for the same underlying reason: they either add something to run or they rely on people behaving.
| Approach | What it gives you | Why we did not use it |
|---|---|---|
| External database lock table |
Proper locking semantics, familiar to anyone with a database background | New infrastructure, new credentials, new failure point sitting between the user and a calculation |
| Process discipline procedure and etiquette |
Nothing to build | Depends on users remembering, and on knowing what everyone else is doing right now |
| Job Console monitoring native visibility |
An accurate record of what ran and when | It tells you about the collision after it has happened, which is the wrong end of the problem |
| Substitution variables the one we chose |
Application-wide, persistent, readable and writable through the Planning REST API, already part of the product | Chosen. The trade-off is that you write the locking semantics yourself, which is the rest of this post |
The decision underneath that table matters more than the mechanism. Execution ownership had to live in the application, not in the user’s session. Sessions are temporary things. A browser tab is not a reliable place to store the fact that a critical planning process is currently running.
What the lock actually stores
Each rule gets its own lock variable, named from the rule itself, so two unrelated processes never contend for the same lock. The value carries four pieces of information in one string:
RUNNING_<timestamp>_<executionId>_<username>
RUNNING execution state
timestamp when the lock was acquired, in milliseconds
executionId a UUID generated at acquisition
username who acquired it
Four fields in one variable, with no schema to maintain. The timestamp tells a later execution how old the lock is. The execution id makes the value unique to this run, which matters because the ownership check at the end compares the whole string. Two executions by the same person carry different lock values, and that is what makes the check at release worth anything. The first version relied on the millisecond timestamp for that uniqueness, which holds right up until two acquisitions land in the same millisecond.
Blocking a parallel rule execution
When the rule starts, it reads its own lock variable before touching any business logic. If a lock is present and its age is inside the timeout threshold, the execution stops there and the user gets a message.
The wording of that message does more work than it looks like it should. “Rule already running” produces a support ticket. “Started by j.smith 4 minutes ago” produces a short conversation between two people who work together, and no ticket at all.
Recovering an abandoned lock
Blocking is the easy half. What decides whether a lock framework survives contact with a real close is the case where an execution never finishes.
The rule fails in a way that skips its own cleanup. The user closes the browser mid-run. The network drops. In every one of those cases the lock outlives the execution that created it, and the process stays blocked until an administrator deletes the variable by hand. Build the framework without recovery and you have replaced an occasional data problem with a permanent operational one.
This is where the timestamp earns its place. A later execution that finds an existing lock works out how old it is. Inside the threshold, the lock is live and the request is refused. Past the threshold, the lock is treated as abandoned, replaced with a fresh one, and processing continues. An abandoned lock stops being an incident and becomes something that clears itself after a known interval.
The threshold is derived per rule rather than set globally, because a single number cannot suit a 30 second allocation and a five minute consolidation at the same time. It comes out as twice the expected runtime, or the expected runtime plus 150 seconds, whichever is larger. A 30 second rule gets 180 seconds, a five minute rule gets 600. The floor matters more than the multiplier. A flat percentage margin looks generous on a short rule and is thin on a long one, and close week is exactly when a five minute rule takes seven.
The ownership trap
Stale-lock recovery introduces a subtle problem of its own, and it is the part of the design that most implementations miss.
User A acquires the lock and runs long. The execution passes the timeout threshold. User B arrives, finds what looks like a stale lock, recovers it legitimately, and starts work. Then User A finally finishes and runs its cleanup step.
If that cleanup deletes whatever lock it happens to find, it deletes User B’s live lock, and the framework has just produced the exact condition it exists to prevent.
The fix is one comparison. Before releasing, the rule reads the lock again and checks the value against the one it wrote. If they match, delete it. If they do not, leave it alone and write a line to the job log, because ownership has already moved on. Leave the comparison out and the recovery mechanism will eventually damage the state it was built to protect.
Control without extra access
Nothing here widens anyone’s access, but the way that works is worth being precise about. Substitution variable access through the REST API is governed by role, and Oracle tightened it in the September 2025 update, when the read commands were limited to Service Administrators rather than Power Users with rule launch access. End users do not have that.
The REST calls run through a named connection holding stored service credentials, the same pattern used for self-service CLS and DataMap execution. Credentials stay out of the script, which is the reason to use a named connection rather than an on-demand one. The user does not hold the elevated privilege. The connection does, and only the rules that reference it can use it.
That distinction matters when someone from security asks. Users get no new roles. What they get is a rule that refuses to start when it shouldn’t start.
From an audit position the framework reads well. Executions all follow the same path. The lock carries who owns it and when it was taken, recovery from an abandoned lock follows a defined route instead of an ad hoc administrator intervention, and releases are validated before they happen. The security model doesn’t move, and the operational controls sitting around it get stronger.
What it does not do
This is a concurrency control, not a distributed lock, and the difference is worth stating plainly.
The sequence is read, check, write, which is three REST operations rather than one. Substitution variables have no compare-and-swap, so there is a window between the read and the write in which a second execution can read the same empty lock and write its own.
The template narrows that window by reading the variable back immediately after writing it. If the value on the server is no longer the one this execution wrote, the rule stops before running any business logic, and it deletes nothing on the way out, because the lock belongs to whoever won. That catches the case where the competing write landed first.
It does not close the window. If both executions write and both read back before seeing the other write, both see their own value and both proceed. Nothing available inside Planning fixes that. The residual case needs two launches within the same moment, which is a much rarer event than the thirty second double-click this exists to stop, but it is not zero.
The other gap is at the far end of the threshold. If a run genuinely exceeds its timeout, the lock looks stale to the next execution, gets recovered, and parallel execution becomes possible again. There is no heartbeat re-stamping the timestamp mid-run. That is the reason the threshold is a per-rule setting with a deliberate buffer rather than one global number, and the reason it is worth checking against real Job Console durations rather than an estimate.
Neither of these makes the framework less useful for what it was built for. They do mean it is the wrong tool if what you need is a hard guarantee on a financial posting process.
If you build this yourself
- Keep execution state in the application, never in the user session
- Put a timestamp in every lock, or you are one abandoned session away from a permanent block
- Validate ownership before release, otherwise recovery quietly breaks the thing it recovered
- Set the timeout longer than your worst realistic run time, then confirm it against actual Job Console durations
- Be honest that read, check, write is not atomic, and decide whether the residual window matters for the process you are protecting
The template
The whole thing is one block at the top of the rule and one finally at the bottom. The calc script sits in the middle, untouched.
/*
* RULE EXECUTION LOCK FRAMEWORK, version 3.0
*
* Prevents accidental parallel execution of the same Groovy business
* rule using an application substitution variable as a server-side
* execution marker.
*
* This is concurrency control, not a distributed lock. Substitution
* variables have no compare-and-swap, so read, check, write is not
* atomic. The read-back after acquire catches the common
* last-writer-wins case. It cannot guarantee mutual exclusion if two
* executions interleave perfectly.
*
* Lock value: RUNNING_<acquireMillis>_<executionId>_<username>
*/
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import java.util.UUID
// ---------- configuration ----------
// Take this from Job Console durations, not from an estimate.
int expectedRuntimeSeconds = 120
// Twice the expected runtime, or expected plus 150 seconds, whichever
// is larger. The floor matters more than the multiplier: a percentage
// margin on a long rule is thin on a busy day, and a rule that runs
// past its timeout can have its lock recovered mid-execution.
int lockTimeoutSeconds =
Math.max(expectedRuntimeSeconds * 2, expectedRuntimeSeconds + 150)
final int MAX_VAR_NAME = 78
String connectionName = "EPMConnect_Subvar_Admin"
String appName = operation.application.getName()
String currentRuleName = operation.hasCurrentRule() ? operation.currentRule.getName() : null
if (!currentRuleName) {
throwVetoException("Could not determine current rule name.")
}
def connection = operation.application.getConnection(connectionName)
// ---------- lock variable name, capped and hashed if too long ----------
String candidateVarName = "LOCK_" + currentRuleName.replaceAll("[^a-zA-Z0-9_]", "_")
String hashSuffix = Integer.toHexString(currentRuleName.hashCode())
String lockVarName =
candidateVarName.length() <= MAX_VAR_NAME
? candidateVarName
: candidateVarName.substring(0, MAX_VAR_NAME - hashSuffix.length() - 1) + "_" + hashSuffix
String lockVarUrl = "/HyperionPlanning/rest/v3/applications/${appName}/substitutionvariables/${lockVarName}"
String lockCollectionUrl = "/HyperionPlanning/rest/v3/applications/${appName}/substitutionvariables"
// ---------- read ----------
def readLockValue = {
try {
HttpResponse<String> resp = connection.get(lockVarUrl).asString()
if (resp.status == 200) {
Map json = (Map)new JsonSlurper().parseText(resp.body)
return json["value"]?.toString()
}
} catch (Exception ignore) { }
return null
}
String currentLockValue = readLockValue()
// ---------- check its age ----------
if (currentLockValue?.startsWith("RUNNING_")) {
List<String> lockParts = currentLockValue.substring("RUNNING_".length()).tokenize("_")
String timestampPart = lockParts.isEmpty() ? null : lockParts[0]
String lockOwner = "Unknown User"
// Discriminate on the shape of the second token, not the token
// count, so a username containing an underscore is not misread
// as an execution id.
if (lockParts.size() >= 2) {
boolean hasExecutionId = lockParts[1] ==~
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/
lockOwner = hasExecutionId
? (lockParts.size() > 2 ? lockParts[2..-1].join("_") : "Unknown User")
: lockParts[1..-1].join("_")
}
// 0 makes elapsedSeconds very large, which treats an unusable
// value as stale. Recover rather than block.
long lockSetMillis = 0L
try {
lockSetMillis = timestampPart == null ? 0L : Long.parseLong(timestampPart)
} catch (Exception ignore) {
println "WARNING: invalid lock value '${currentLockValue}'. Treating as stale."
}
long elapsedSeconds = Math.floorDiv(new Date().getTime() - lockSetMillis, 1000L)
if (elapsedSeconds < lockTimeoutSeconds) {
throwVetoException(
"Rule '${currentRuleName}' is already running by '${lockOwner}' " +
"(started ${elapsedSeconds} seconds ago). Please wait for it to finish.")
} else {
println "Lock is stale (${elapsedSeconds}s old, threshold ${lockTimeoutSeconds}s). Overwriting."
}
}
// ---------- acquire ----------
String myLockValue =
"RUNNING_${new Date().getTime()}_${UUID.randomUUID().toString()}_${operation.user.getName()}"
// Built from a map rather than interpolated, so a username containing
// a quote or a backslash cannot break the request body.
String requestBody = JsonOutput.toJson([
items: [[ name: lockVarName, value: myLockValue, planType: "ALL" ]]
])
HttpResponse<String> saveResp = connection
.post(lockCollectionUrl)
.header("Content-Type", "application/json")
.body(requestBody)
.asString()
if (!(200..299).contains(saveResp.status)) {
throwVetoException("Failed to acquire execution lock. Status ${saveResp.status}")
}
// ---------- verify the acquire ----------
String confirmedLockValue = readLockValue()
if (confirmedLockValue == null) {
confirmedLockValue = readLockValue()
}
if (confirmedLockValue == null) {
// The write succeeded but the read did not, so we cannot prove
// our value is the one on the server. Vetoing here would probably
// abandon our own lock until the timeout expires, so continue.
println "WARNING: could not verify lock '${lockVarName}'. Proceeding on the successful write."
} else if (confirmedLockValue != myLockValue) {
// Nothing is deleted here. At this point the lock belongs to the
// execution that won the race.
throwVetoException(
"Another execution acquired the lock for '${currentRuleName}' at the same moment. " +
"Please try again in a few seconds.")
}
println "Lock acquired: ${lockVarName}"
// ---------- run, then release only if the lock is still ours ----------
try {
Cube cube = operation.application.getCube("OEP_FS")
cube.executeCalcScript(""" /* your business rule here */ """)
} finally {
try {
String latestLockValue = readLockValue()
if (latestLockValue == null) {
latestLockValue = readLockValue()
}
if (latestLockValue == myLockValue) {
HttpResponse<String> deleteResp = connection.delete(lockVarUrl).asString()
if ((200..299).contains(deleteResp.status)) {
println "Lock released: ${lockVarName}"
} else {
println "WARNING: failed to delete lock (${deleteResp.status}). It will clear on timeout."
}
} else if (latestLockValue == null) {
println "WARNING: could not read lock during release. It will clear on the stale timeout."
} else {
println "Lock ownership lost. Expected '${myLockValue}', found '${latestLockValue}'. Not deleted."
}
} catch (Exception ex) {
println "WARNING: error releasing lock: ${ex.message}"
}
}
Treating rules as shared resources
What’s worth taking from this build is the idea underneath the Groovy. A business rule is a shared resource, and shared resources need execution control in the same way a shared file needs a lock.
Substitution variables, the REST API, a timeout and an ownership check are enough to give any Oracle EPM Cloud rule that control, without adding anything to the environment or to anyone’s security profile. In execution terms it costs two REST calls at the start and two at the end.
Related reading on the same design principle: CLS and DataMap self-service in EPBCS, where privilege lives in the connection rather than in the user.