cloudflare / cloudflare/workers-sdk

Workflows local binding does not emulate deterministic-ID uniqueness: duplicate create()/createBatch() neither throw nor skip, and can double-execute the workflow

Open
#14,836 1 comment 0 reactions 0 assignees View on GitHub
package:workflows-shared product:workflows
Dominant language
TypeScript
Stars
4.5k
Forks
1.5k
Avg merge
3d 8h
Merged PRs (30d)
187

Description

### Which Cloudflare product(s) does this pertain to?

Workflows (local development / miniflare emulation), Miniflare, `@cloudflare/vitest-pool-workers`

### What versions are you using?

- wrangler `4.114.0`
- miniflare `4.20260722.0`
- workerd `1.20260722.1`
- `@cloudflare/vitest-pool-workers` `0.18.8`
- vitest `4.1.2`

### What operating system and version are you using?

Linux 6.12 (x86_64)

### Describe the bug

The local Workflows binding does not emulate the documented deterministic-ID uniqueness contract, and the divergence is in the dangerous direction: code that relies on the documented production behavior ("duplicate create with the same ID is safely deduplicated") appears to work locally while actually **double-executing** the workflow body.

Documented production contract ([Workers API docs](https://developers.cloudflare.com/workflows/build/workers-api/#createbatch)):

- `create({ id })` with an existing ID **throws**, the existing instance is retained.
- `createBatch([{ id }])` is idempotent: retained IDs are **skipped and excluded** from the result.

Observed local behavior (vitest-pool-workers harness, versions above):

1. `createBatch([{ id }])` with an existing ID returns **length 1** (expected: `[]`).
2. `create({ id })` with an existing ID does **not throw**.
3. Duplicate creates can **double-execute** the workflow: a counter incremented inside the first `step.do()` body observed the step running twice across duplicate creates (racy — not deterministic run to run).

The cause is visible in the current local binding implementation: [`create()` starts initialization inside `waitUntil` and suppresses initialization rejections, returning the ID immediately](https://github.com/cloudflare/workers-sdk/blob/6e0bf6e917bf4a2b9cd3ee741e625174075e38e1/packages/workflows-shared/src/binding.ts#L144-L205), and [`createBatch()` simply maps every input through `create()` and returns every result](https://github.com/cloudflare/workers-sdk/blob/6e0bf6e917bf4a2b9cd3ee741e625174075e38e1/packages/workflows-shared/src/binding.ts#L226-L240) — there is no uniqueness check on either path.

### Why this matters

Deterministic instance IDs are the documented mechanism for making at-least-once triggers (e.g. a Queue consumer creating one workflow per message) idempotent. Because the emulator neither throws nor skips **nor coalesces execution**, local tests actively validate the wrong behavior: a design whose core safety property is "duplicate delivery = free no-op" passes locally and can only be truthfully validated on a deployed environment (remote bindings / `wrangler dev --remote` being unsupported for Workflows per the [local-development known issues](https://developers.cloudflare.com/workflows/build/local-development/#known-issues)).

### Reproduction

Minimal test (vitest-pool-workers, a workflow whose first step increments a counter):

```ts
import { env, introspectWorkflowInstance } from 'cloudflare:test'
import { expect, it } from 'vitest'

it('deterministic-ID dedup (matches prod docs; fails locally)', async () => {
const id = 'dedup-repro-1'

await using instance = await introspectWorkflowInstance(env.MY_WORKFLOW, id)
await instance.modify(async (m) => {
await m.disableSleeps()
})

const first = await env.MY_WORKFLOW.createBatch([{ id, params: {} }])
const second = await env.MY_WORKFLOW.createBatch([{ id, params: {} }])

let createThrew = false
try {
await env.MY_WORKFLOW.create({ id, params: {} })
} catch {
createThrew = true
}
await instance.waitForStatus('complete')

expect(first.length).toBe(1)
expect(second.length).toBe(0) // FAILS locally: returns 1
expect(createThrew).toBe(true) // FAILS locally: no throw
// side-effect counter inside the first step.do() body: observed 2 under
// duplicate creates (racy), i.e. the body double-executed
})
```

### Expected behavior

Local `create()`/`createBatch()` match the documented production semantics (throw / skip-and-exclude), or at minimum coalesce execution per ID so duplicate creates cannot double-run step bodies — plus a documented known-issue note until then, since this is exactly the property idempotent queue-consumer patterns depend on.

I could not find this mismatch in the published known issues or an existing issue here. Happy to provide the full spike harness if useful.

Contributor guide

Open the contributing guide

Research direction

Start in packages/workflows-shared/src/binding.ts at the create() and createBatch() implementations linked in the issue, then run the provided Vitest reproduction in the local Workflows harness. Compare duplicate-ID behavior with the documented throw and skip-and-exclude semantics, and verify that duplicate creates cannot execute the workflow body twice.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.