flowable/flowable-engine · error · FlowableIllegalArgumentException
{workerId} does not hold a lock on the requested job
Error message
{workerId} does not hold a lock on the requested job What it means
resolveJob compares the supplied workerId with the job's lockOwner and throws FlowableIllegalArgumentException when they differ. Only the worker that currently holds the lock is allowed to complete, fail, or release the job, preventing one worker from manipulating another worker's job.
Source
Thrown at modules/flowable-engine/src/main/java/org/flowable/engine/impl/cmd/AbstractExternalWorkerJobCmd.java:91
protected ExternalWorkerJobEntity resolveJob(CommandContext commandContext) {
if (StringUtils.isEmpty(externalJobId)) {
throw new FlowableIllegalArgumentException("externalJobId must not be empty");
}
if (StringUtils.isEmpty(workerId)) {
throw new FlowableIllegalArgumentException("workerId must not be empty");
}
ExternalWorkerJobEntityManager externalWorkerJobEntityManager = jobServiceConfiguration.getExternalWorkerJobEntityManager();
ExternalWorkerJobEntity job = externalWorkerJobEntityManager.findById(externalJobId);
if (job == null) {
throw new FlowableObjectNotFoundException("No External Worker job found for id: " + externalJobId, ExternalWorkerJobEntity.class);
}
if (!Objects.equals(workerId, job.getLockOwner())) {
throw new FlowableIllegalArgumentException(workerId + " does not hold a lock on the requested job");
}
return job;
}
}
View on GitHub (pinned to d6d39ce1c6)
Solutions
- Use the exact workerId that acquired the job for all subsequent complete/fail/release calls on it.
- Give each worker instance a stable unique workerId (e.g. hostname+PID persisted across restarts).
- If the lock was lost, treat the job as no longer owned and re-acquire; optionally catch the exception and refresh the job state.
Example fix
// before service.complete(job.getId(), "shared-worker", variables); // lock held by worker-7 // after service.complete(job.getId(), job.getLockOwner(), variables); // pass acquiring workerId
Defensive patterns
Strategy: try-catch
Validate before calling
ExternalWorkerJob job = jobService.createExternalWorkerJobQuery().externalJobId(jobId).singleResult();
if (job != null && !Objects.equals(workerId, job.getLockOwner())) {
// lock lost: re-acquire instead of completing
} Try / catch
try {
jobService.complete(jobId, workerId, variables);
} catch (FlowableIllegalArgumentException e) {
if (e.getMessage().contains("does not hold a lock")) {
// lock expired or lost: re-acquire the job
}
} Prevention
- Use a stable unique workerId per worker instance
- Complete jobs within the acquisition lock duration
- Handle lock-lost as a normal concurrency outcome, not a bug
When it happens
Trigger: Calling complete/fail/release with a workerId different from the one used at acquisition time (job.getLockOwner()), e.g. after the lock expired and was re-acquired by another worker, or with a duplicate/default workerId.
Common situations: Multiple workers sharing a hardcoded workerId then colliding on lock ownership; job lock expired and re-acquired by a different instance; a restart generated a new workerId while the client reused an old job reference; load-balanced consumers using per-request ids.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- ${workerId} does not hold a lock on the requested job
- Could not find a scope execution for compensation boundary e
- {externalWorkerJob} is not bpmn scoped. This command can onl
- externalJobId must not be empty
- workerId must not be empty
AI-assisted analysis of flowable/flowable-engine@d6d39ce1c6 (2026-09-11).
Data as JSON: /api/errors/127ee67e3f64fe52.
Report an issue: GitHub.