Skip to content

Add a workflow ID reuse policy that can reject a taken ID - #650

Merged
devhawk merged 7 commits into
mainfrom
workflow-id-reuse-policy
Oct 28, 2026
Merged

devhawk merged 7 commits into
mainfrom
workflow-id-reuse-policy

Conversation

@devhawk

@devhawk devhawk commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

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.
  • REJECT throws DBOSWorkflowIdInUseException whatever the existing workflow's status, and leaves that workflow as it was. The exception carries workflowId(), workflowStatus() and workflowName().

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, for startWorkflow with or without a queue.
  • EnqueueOptions.withWorkflowIdReusePolicy, for DBOS.enqueueWorkflow and DBOSClient.enqueueWorkflow.
  • WorkflowOptions.withWorkflowIdReusePolicy, for a workflow called directly inside the block.

The WorkflowOptions policy goes with the block's ID:

  • It applies to whichever workflow takes that ID, whether called directly, started or enqueued.
  • A call that gives its own ID isn't governed by it.
  • A nested block that sets another ID drops it.
  • A policy given with the call takes precedence over it.
  • setContext() throws IllegalArgumentException for 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. With REJECT, 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 REJECT throws IllegalArgumentException.

Behaviour change for direct calls

WorkflowOptions blocks 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:

try (var o = new WorkflowOptions("A").setContext()) {
  try (var t = new WorkflowOptions().withTimeout(d).setContext()) { proxy.wf1(); } // creates A
  proxy.wf2(); // used to take "A" again; now gets an ID of its own
}

With REJECT, the old behaviour would refuse wf2. Once used, the block's ID now stays used, as Python's SetWorkflowID does. This also applies to blocks that set no policy.

API changes

  • StartWorkflowOptions, EnqueueOptions and WorkflowOptions each gain a workflowIdReusePolicy component, so their canonical constructors take one more argument. The convenience constructors and withX methods are unchanged.
  • Internal: SystemDatabase.initWorkflowStatus and initChildWorkflowStatus take the policy as a parameter. DBOSContext.getNextWorkflowId becomes takeNextWorkflowId, 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:

  • refusal over every existing status: PENDING, ENQUEUED, DELAYED, SUCCESS, ERROR and CANCELLED, with that workflow's row left unchanged;
  • a call naming another workflow, refused as in use;
  • the deduplication ID versus the workflow ID;
  • an insert retried after a lost acknowledgement, for a start, a runtime enqueue and a client enqueue;
  • RETURN_EXISTING, explicit and by default;
  • refused children for a start, an enqueue and a direct call, each recorded at the parent and replayed;
  • the WorkflowOptions rules above, including a call's policy overriding the block's and the behaviour change;
  • the debounce refusal.

WorkflowInsertOwnershipTest adds 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, WorkflowAttributesTest and DebouncerTest. spotlessCheck, compile across all modules and javadoc pass. I haven't run the full clean build locally.

🤖 Generated with Claude Code

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.
@devhawk
devhawk requested review from kraftp, maxdml and qianl15 and a balanced review from Copilot October 28, 2026 19:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.
@devhawk
devhawk merged commit 88ad9ef into main Oct 28, 2026
11 checks passed
@devhawk
devhawk deleted the workflow-id-reuse-policy branch October 28, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants