Repository navigation
Description
What happened:
A WorkflowInstance that is created but never started stays in WorkflowDefinition.activeInstances for the lifetime of the JVM, and there is no supported way to discard it.
WorkflowDefinition.instance(Object) registers the instance eagerly — WorkflowMutableInstance's constructor ends with:
definition.addInstance(this); // activeInstances.put(id(), instance)and the entry is only ever removed by cleanUp, which runs when a run reaches a terminal state.
That is fine for startInstance(...), where creation and start are one call. It is a leak for callers that split the two, which the API invites by exposing instance(Object) separately from WorkflowInstance.start(). Any caller that decides after creating the instance that it should not run — or whose start() throws — leaks the entry.
The obvious cleanup does not work. cancel() is a no-op on a never-started instance:
// WorkflowMutableInstance.internalCancel()
if (TaskExecutorHelper.isActive(status.get())) { ... status.getAndSet(CANCELLED) ... }
else { previous = CANCELLED; }TaskExecutorHelper.isActive(WorkflowStatus) is true only for RUNNING, WAITING and SUSPENDED. A freshly constructed instance is PENDING (the constructor does new AtomicReference<>(WorkflowStatus.PENDING)), so internalCancel() takes the else branch, cancel() returns false, publishStatusChange is never called, cleanUp never runs, and the entry remains. WorkflowDefinition.removeInstance is package-private, so there is no alternative.
The result is that cancel() on a PENDING instance looks like cleanup and silently does nothing — arguably the worse half of this report.
Why we hit it: our application needs the instance's id before it starts the run, because the id is written to a database row that claims ownership of a piece of work (so that a concurrently-arriving event can find the run). If that claim loses an optimistic-locking race, the run must not start — and the instance we created is then unreachable but permanently registered. We also hit it on the plainer path where start() itself throws.
What you expected to happen:
One of:
cancel()works on aPENDINGinstance — treatPENDINGas cancellable, so it transitions toCANCELLED, publishes the status change and runscleanUp. This seems the most consistent with the existing model:PENDINGis a non-terminal state, and "cancel something that has not started" is the least surprising reading ofcancel().- Or a public way to discard an unstarted instance (
WorkflowDefinition.removeInstance(WorkflowInstance)made public, orWorkflowInstance.discard()). - Or registration deferred until
start(), soinstance(...)has no side effect on the definition. This is the cleanest but the most behaviour-changing, sinceactiveInstance(id)would stop finding a created-not-started instance.
Happy to open a PR for whichever option you prefer — (1) looks like a small change in internalCancel/isActive.
How to reproduce it:
var definition = ...;
var instance = definition.instance(input); // never started
assertThat(instance.cancel()).isFalse(); // no-op
assertThat(definition.activeInstance(instance.id())).isPresent(); // still registered, foreverEnvironment:
io.serverlessworkflow:serverlessworkflow-impl-core7.34.0.Final (as brought in byio.quarkiverse.flow:quarkus-flow1.3.0)- Java 25
Activity
Verify the email intake and wrap up the feature (task 14 of #216)
Verify the email intake and wrap up the feature (task 14 of #216) (#287)
fjtirado commentedon Nov 5, 2026
Well I would say that the obvious workaround, without any PR, is to call start and then cancel.
Pending is an intermediate step, it is expected that all restored instances are started at startup. Or they should not be restored at all.
[Fix open-workflow-specification#1816] Remove cancelled pending insta…
[Fix open-workflow-specification#1816] Remove restored instance cance…
edeandrea commentedon Nov 9, 2026
Thanks @fjtirado. Unfortunately start-then-cancel doesn't help in our case, because
start()runs the workflow beforecancel()can be called.WorkflowMutableInstance.startExecution→status(RUNNING)→publishEvent→TaskExecutorHelper.processTaskList→AbstractTaskExecutor.applyis a chain ofthenComposecalls on already-completed futures. It runs on the caller's thread until a task hands work toexecutorService()(fork, wait, run script/shell, listen). By the timestart()returns:onWorkflowStarted/onTaskStarted/onTaskCompletedhave been published, so persistence, CloudEvents and metrics listeners have recorded a run.In our scenario the run must not start: the instance id is written to a DB row to claim a piece of work, and if that claim loses the optimistic-lock race, nothing may execute.
Also, this isn't the restore path. We aren't restoring anything. We call the public
definition.instance(input)and later decide whether tostart()it. IfPENDINGis only meant to be an intermediate state, then a publicinstance()shouldn't leave a permanent entry inactiveInstanceswith no way to remove it.I wrote a reproducer against current
main(7176bab) with a workflow of two synchronoussettasks. The assertions describe the behavior we'd expect, and both fail today:Full reproducer:
impl/test/src/test/java/org/openworkflow/sdk/impl/test/UnstartedInstanceTest.javaRun with:
mvn -pl impl/test -am -Dtest=UnstartedInstanceTest -Dsurefire.failIfNoSpecifiedTests=false testSo I think we still need one of the three options in the issue description. Your "PENDING is an intermediate step" point fits option 3 best (register the instance in
activeInstancesonly onstart()), though option 1 is the smallest change. Which would you prefer? I'm happy to open the PR.edeandrea commentedon Nov 10, 2026
One more way into this that the caller doesn't control, so start-then-cancel can't help: reading a persisted
instance registers one.
AbstractPersistenceInstanceReader.find/scanAllmap every persisted row throughWorkflowPersistenceInstance.of,which builds a new instance when none is active, and the
WorkflowMutableInstanceconstructor registers it(
definition.addInstance(this)). So a lookup from a JVM that doesn't host the run (another replica, or after a restartwith restore off) leaves a
PENDINGinstance inactiveInstancesfor good. That has two consequences:PENDINGwhatever was persisted, sofind(...).status()can't tell a waiting run from anything else. (With the JPA store the persisted status isnullfor a running or waiting run anyway, so a reader couldn't do much better, but
PENDINGlooks like an answer.)activeInstance(id)in that JVM. Afterwards the run looks local, andcancel()on it returnsfalse(the
PENDINGno-op described above).Reproduced with sdk-java 7.35.3.Final through quarkus-flow 1.3.0 and quarkus-flow-jpa (a real run driven to
WAITING,then removed from
activeInstancesto stand in for another JVM):reader.find(definition, id)WorkflowPersistenceInstance,PENDINGdefinition.activeInstance(id)afterwardsPENDINGcancel()on itfalse; the persisted rows stayThe same code is on
main(org.openworkflow.sdk.impl.persistence.WorkflowPersistenceInstance.of).#1820 gives a way to clean up after the fact, but it doesn't stop a read from having the side effect. And with it,
cancelling the instance a read left behind publishes the cancellation, which
WorkflowPersistenceListenerturns intoaborted→removeProcessInstance: it deletes the persisted run, which is rarely what someone who only looked it upwants. That's an argument for option 3 (register on
start()), or at least forfind/scanAllnot registering whatthey build until it's started.
Correct the spike's auto-restore findings and link upstream (#284)
fjtirado commentedon Nov 10, 2026
@edeandrea
I'm thinking we should automatically start when restoring (so restored instance cannot be in pending status and it is ok to not cancel what has not been started. so #1820 is not needed).
The reason I did not do that, restore in started was because I thought it give more flexibility, but not really, it is leading to weird scenarios that the one you spotted.
Edited:
Hmmm, no, because it will lead to other issues (you can read the instance for a different reason and then you will be starting the instance in two different JVMS, what is precisely what we want to avoid)
What really needs to be done is that the instance is not added to the map of active instances till is started (which is kind of weird) and change any code that assume that.
Still pending instances will not be cancelled, which makes sense to me (once that the pending instances are not kept anywhere, that should not be an issue)
edeandrea commentedon Nov 10, 2026
Thanks @fjtirado, registering on
start()works for me. It removes both problems: a created-but-never-started instance no longer leaks, and afind/scanAlllookup no longer leaves anything inactiveInstances. Agreed thatcancel()on aPENDINGinstance can then stay a no-op, since there's nothing left to clean up.Since #1820 takes a different approach (making
PENDINGcancellable), I'm happy to go either way: open my own PR for this, or review/comment on #1820 and let @NopeCharon rework it. Whatever you prefer. Either way, a few things the change will need to cover:WorkflowPersistenceInstance.ofis the only place onmainthat relies on registration at construction (activeInstance(id).orElseGet(...)). It still works for a run that has started in this JVM, but it no longer deduplicates unstarted ones, so two lookups of the same id return two separate instances.startExecution: it should useputIfAbsent(or equivalent) so that starting a second instance with an id that's already active fails instead of silently replacing the running one.removeInstanceshould remove only if the entry is still this same instance (remove(id, instance), as [Fix #1816] Allow cancelling an instance that has never been started #1820 already does). Otherwise a stale instance's cleanup can unregister a different one with the same id.One leftover from my previous comment that this doesn't address: an instance returned by
find/scanAllstill reportsPENDING, whatever its persisted status. For a read-only lookup, it would be more useful forstatus()to return the persisted status where the store has one (PersistenceWorkflowInfo.status()), or for the javadoc to say that the status of an unstarted, read instance isn't meaningful. I'm fine with either, butPENDINGcurrently looks like a real answer.fjtirado commentedon Nov 10, 2026
Lets go for register with start. There is not problem on creating two pending instances with the same id as far as only one is started (which should now be controlled in start rather than in of)
About the leftover, lets document that the status of a read instance is not meaningful (thats not exactly true, but it is safer)
Ill add the new listener I mentioned in my previous comment with a different, future issue.
edeandrea commentedon Nov 10, 2026
Sounds good, thanks @fjtirado. To recap: register in
start()(and enforce there that only one instance per id is active), drop the dedup inWorkflowPersistenceInstance.of, leavecancel()onPENDINGas a no-op, and document that the status of an instance obtained by reading from persistence isn't meaningful.Who should take it? I'm happy to open a new PR, or @NopeCharon can rework #1820 along these lines and I'll review. Either is fine with me.
fjtirado commentedon Nov 10, 2026
Please open a new PR following these lines and lets close #1820.
edeandrea commentedon Nov 10, 2026
#1827 opened!
[Fix open-workflow-specification#1816] Address javadoc review comments
[Fix #1816] Register workflow instances in the definition when started (
[7.x] [Fix #1816] Register workflow instances in the definition when …
Make the email intake survive restarts (single replica) (#297)