Repository navigation
Add a workflow ID reuse policy that can reject a taken ID - #650
Merged
Merged
Conversation
Starting or enqueueing a workflow under an ID that is already taken has always attached to the existing workflow. WorkflowIdReusePolicy.REJECT refuses instead, with DBOSWorkflowIdInUseException, whatever the existing workflow's status, and writes nothing. RETURN_EXISTING, the default, keeps today's behaviour. Python, TypeScript and Go have the same policy (workflow_id_reuse_policy / workflowIDReusePolicy). - StartWorkflowOptions and EnqueueOptions gain workflowIdReusePolicy and withWorkflowIdReusePolicy, so it applies to starts, runtime enqueues and client enqueues. - The insert already writes a fresh creator token and returns the row's; a different token means another call created the row, and REJECT throws, before anything else is checked. The transaction rolls back, and the insert writes no input for a row it did not create. A retry whose first attempt committed but lost its acknowledgement finds its own token, and is not refused. - DBOSWorkflowIdInUseException carries the ID, the existing workflow's status and its name, with the same message as Python's. - A child start or enqueue refused this way is recorded at the parent's step, as a deduplication collision already is, so a replay throws it again even once the ID is free. - REJECT on a debounced workflow is refused: a debounce owns its workflow's ID. No public debounce option reaches it today.
# Conflicts: # transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java
WorkflowIdReusePolicyTest also covers: - a holder in each status the other SDKs test: ENQUEUED, DELAYED and CANCELLED as well as PENDING, SUCCESS and ERROR; - a call naming another workflow, refused as in use, since the ID is checked before the name; - deduplication: the same ID is in use whatever its deduplication ID, and another ID meets the deduplication collision; - a start, runtime enqueue and client enqueue whose commit landed but whose acknowledgement was lost, retried with the same token and not refused; - a runtime enqueueWorkflow(EnqueueOptions) on a taken ID; - a parent starting the same child ID twice: the second is refused, and a replay attaches to the first and throws the recorded refusal.
The policy governs a single insert and is never stored, so it is an argument to initWorkflowStatus alongside the creator token and the retry budget, as Python's init_workflow and TypeScript's initWorkflowStatus take it, not a component of WorkflowStatusInternal. The guard against a debounced workflow rejecting moves to ExecutionOptions, which holds both.
A workflow called directly takes its ID from WorkflowOptions, and had no way to reject a taken one. The policy is set with the ID and taken with it, so it governs only the workflow that takes that ID, whether called directly, started or enqueued; a call giving its own ID, or a nested block setting another, is not governed by it, and options given for the call override it. Python's SetWorkflowID takes the policy the same way. A debounce refuses an ambient REJECT, as it refuses one given for the call.
A block's guard restored the next workflow ID on close even when the block set none, so an ID a workflow took inside a nested block was handed out again after it closed: the next workflow attached to the first, or, under REJECT, was refused. The ID, its policy and whether it is taken are now one object the guards share by reference, so a taken ID stays taken, and the next workflow gets an ID of its own. A debounce refused for an ambient REJECT now takes that ID, so it and its policy do not go on to the caller's next workflow. Tests cover a policy given for the call overriding the block's, the outer policy applying again after a nested block closes, and both fixes. The REJECT and exception docs no longer say nothing is written, since a refused child is recorded at its parent, and say which retry is not refused.
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are identified for the configurable workflow ID reuse changes.
No open issues 🎉
What changed in this PR
Adds configurable workflow ID reuse to the Java SDK, preserving the default attach-to-existing behavior while allowing callers to reject an ID already in use.
Changes:
- Adds reuse policies to start, enqueue, and scoped workflow options, with a dedicated exception for rejected IDs.
- Preserves retry ownership and records child refusals for replay; keeps scoped IDs consumed across nested blocks and rejects debounce use with
REJECT. - Adds policy and insert-ownership tests and updates existing tests for the expanded APIs.
| File | Description |
|---|---|
transact/src/test/java/dev/dbos/transact/workflow/WorkflowIdReusePolicyTest.java |
Tests rejection, retries, scoped policies, and replay. |
transact/src/test/java/dev/dbos/transact/queue/StaticQueuesTest.java |
Updates initialization calls for the policy parameter. |
transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java |
Updates initialization calls for the policy parameter. |
transact/src/test/java/dev/dbos/transact/database/WorkflowInsertOwnershipTest.java |
Tests unchanged rows on rejection and retry ownership. |
transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java |
Adapts database tests to the expanded API. |
transact/src/test/java/dev/dbos/transact/database/RecordStepResultTest.java |
Updates workflow initialization arguments. |
transact/src/test/java/dev/dbos/transact/database/PartitionedDequeueTest.java |
Updates workflow initialization arguments. |
transact/src/test/java/dev/dbos/transact/database/ListenNotifySourceTest.java |
Updates workflow initialization arguments. |
transact/src/test/java/dev/dbos/transact/database/IdleTransactionTimeoutTest.java |
Updates workflow initialization arguments. |
transact/src/test/java/dev/dbos/transact/client/EnqueueOptionsTest.java |
Updates canonical constructor arguments. |
transact/src/main/java/dev/dbos/transact/workflow/WorkflowIdReusePolicy.java |
Defines RETURN_EXISTING and REJECT. |
transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java |
Rejects scoped REJECT policies and consumes assigned IDs. |
transact/src/main/java/dev/dbos/transact/StartWorkflowOptions.java |
Exposes and preserves the start policy. |
transact/src/main/java/dev/dbos/transact/execution/ExecutionOptions.java |
Carries the policy through execution options. |
transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java |
Resolves policies and records child refusals for replay. |
transact/src/main/java/dev/dbos/transact/exceptions/DBOSWorkflowIdInUseException.java |
Adds rejection details for the existing workflow. |
transact/src/main/java/dev/dbos/transact/EnqueueOptions.java |
Exposes and preserves the enqueue policy. |
transact/src/main/java/dev/dbos/transact/DBOSClient.java |
Keeps deprecated option conversion on the default policy. |
transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java |
Passes policies into workflow initialization. |
transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java |
Rejects existing IDs while recognizing insert retries. |
transact/src/main/java/dev/dbos/transact/context/WorkflowOptions.java |
Associates scoped policies with assigned IDs. |
transact/src/main/java/dev/dbos/transact/context/DBOSContext.java |
Tracks ID consumption across nested scopes. |
Review effort: Balanced
The deprecated record stays field-for-field identical to the top-level EnqueueOptions, and its conversion carries every field. It gains the policy and withWorkflowIdReusePolicy, keeps a constructor with its previous components, as it did when applicationName was added, and its conversion now carries the policy rather than dropping it.
kraftp
approved these changes
Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
WorkflowIdReusePolicy, which decides what starting or enqueueing a workflow does when a workflow with its ID already exists:RETURN_EXISTING, the default and today's behaviour, attaches to the existing workflow.REJECTthrowsDBOSWorkflowIdInUseExceptionwhatever the existing workflow's status, and leaves that workflow as it was. The exception carriesworkflowId(),workflowStatus()andworkflowName().Python (
workflow_id_reuse_policy), TypeScript (workflowIDReusePolicy) and Go (WithWorkflowIDReusePolicy) already have this option. The message is the same as theirs.Where it can be set
StartWorkflowOptions.withWorkflowIdReusePolicy, forstartWorkflowwith or without a queue.EnqueueOptions.withWorkflowIdReusePolicy, forDBOS.enqueueWorkflowandDBOSClient.enqueueWorkflow.WorkflowOptions.withWorkflowIdReusePolicy, for a workflow called directly inside the block.The
WorkflowOptionspolicy goes with the block's ID:setContext()throwsIllegalArgumentExceptionfor a policy set without an ID.How it works
The insert already tells whether it created the row, by comparing its creator token with the row's
creator_xid. WithREJECT, a row the insert didn't create is refused before the name and class checks, and the transaction rolls back.The token is created outside the retry loop. So when an insert committed but its acknowledgement was lost, the retry finds its own row and isn't refused. Calling again with the same ID is refused.
A refused child is recorded at its parent's step, as a refused enqueue already is, for a start, an enqueue or a direct call. A replay of the parent throws the recorded refusal again, even after the ID has been freed.
A debounce owns its workflow's ID, so debouncing with
REJECTthrowsIllegalArgumentException.Behaviour change for direct calls
WorkflowOptionsblocks used to save and restore the next workflow ID on close, even when the inner block set no ID. So an ID a workflow had used inside a nested block was handed out again once that block closed:With
REJECT, the old behaviour would refusewf2. Once used, the block's ID now stays used, as Python'sSetWorkflowIDdoes. This also applies to blocks that set no policy.API changes
StartWorkflowOptions,EnqueueOptionsandWorkflowOptionseach gain aworkflowIdReusePolicycomponent, so their canonical constructors take one more argument. The convenience constructors andwithXmethods are unchanged.SystemDatabase.initWorkflowStatusandinitChildWorkflowStatustake the policy as a parameter.DBOSContext.getNextWorkflowIdbecomestakeNextWorkflowId, which returns the ID with its policy.Not included
The exception has no portable error code. A refusal Java records is read back as
DBOSWorkflowIdInUseException, but a refusal recorded by another SDK is not mapped back to it.Testing
WorkflowIdReusePolicyTest, 31 tests:RETURN_EXISTING, explicit and by default;WorkflowOptionsrules above, including a call's policy overriding the block's and the behaviour change;WorkflowInsertOwnershipTestadds two cases at the DAO level: a refused insert writes nothing, and a retry finds its own row.Locally, these pass against Postgres: those two classes plus
DirectInvocationTest,SyncWorkflowTest,AsyncWorkflowTest,ChildEnqueueErrorTest,WorkflowAttributesTestandDebouncerTest. spotlessCheck, compile across all modules and javadoc pass. I haven't run the fullclean buildlocally.🤖 Generated with Claude Code