Skip to content

feat: send Upstash-Workflow-CreatedAt so runs with the same id aren't deduplicated - #214

Merged
CahidArda merged 9 commits into
mainfrom
feat/workflow-created-at-dedup
Oct 1, 2026
Merged

CahidArda merged 9 commits into
mainfrom
feat/workflow-created-at-dedup

Conversation

@CahidArda

Copy link
Copy Markdown
Collaborator

Why

upstash/qstash-server#1129 (QSTH-1101) adds Upstash-Workflow-CreatedAt to the deduplication hash of workflow requests. The server forces content deduplication on every workflow request, and the hash only covered url, body, headers and run id. So a run started again with the same run id and the same data within the 10 minute window was deduplicated against the previous run. It either couldn't start, or got stuck on its first repeated step. This breaks singleton workflows (discussion #192) and invoked workflows that reuse a run id.

The server only adds the header to the hash when it is sent. Old SDKs therefore keep the old behaviour, and this SDK has to send it. The same PR updates the Go workflow SDK in qstash-server/workflow/. This PR does the same for workflow-js.

What changed

WorkflowConfig gets an optional workflowRunCreatedAt. getHeaders (src/qstash/headers.ts) adds Upstash-Workflow-CreatedAt as a raw header when it is set and non-zero. Everything else passes context.workflowRunCreatedAt into it. The context already reads it from the incoming request (src/serve/index.ts). This is the one-helper approach the Go SDK uses with populateWorkflowHeaders.

Mapping to the Go SDK (qstash-server#1129)

Request workflow-js (this PR) Go SDK counterpart
Header builder WorkflowHeaders.addBaseHeaders() sets Upstash-Workflow-CreatedAt when workflowConfig.workflowRunCreatedAt is set (src/qstash/headers.ts) populateWorkflowHeaders() sets CreatedAtHeader when meta.workflowCreatedAt != 0 (workflow/workflow.go)
Reading it from the incoming request already there: workflowRunCreatedAt from the header in src/serve/index.ts new: parseIncomingRequestMeta() reads it into meta.workflowCreatedAt
Next step BaseLazyStep.getHeaders() (src/context/steps.ts) callPublishForNextStep() → populateWorkflowHeaders(..., meta)
Parallel steps submitParallelSteps() (src/qstash/submit-steps.ts) same populateWorkflowHeaders path
waitForEvent + its timeout message LazyWaitEventStep uses the step headers for the /v2/wait request, and timeoutHeaders is built from them waitnotify.go: populateWorkflowHeaders on both the wait headers and timeoutHeaders
context.call LazyCallStep inherits the step headers, so the call publish carries it callCallbackForNextStep() → populateWorkflowHeaders(..., meta)
context.call result Normal path: QStash builds the result step and now sets the header itself (worker/workflow_call.go); nothing needed in the SDK. Legacy fallback handleThirdPartyCallResult() copies the incoming header onto the result it publishes (src/workflow-requests.ts) No SDK counterpart: Go always relies on the server-side conversion, which sets the header "so that the SDKs do not need to send it as a callback header"
Trigger (run doesn't exist yet) triggerFirstInvocation() sends workflowRunCreatedAt || Date.now(). client.trigger already sets Date.now() once per call, so retries of the same request carry the same value and are still deduplicated populateWorkflowHeadersTrigger(): options.workflowCreatedAt, or time.Now().UnixMilli() when 0
Second phase of the unknown SDK trigger triggerFirstInvocation({ unknownSdk: true }) gets the run's creation time from the incoming request through the context TriggerOptions{..., workflowCreatedAt: meta.workflowCreatedAt} on the meta.unknownSdk path
Invoke: triggering the invoked run LazyInvokeStep.getHeaders() sends the invoker's context.workflowRunCreatedAt callInvokeWorkflow() → populateWorkflowHeaders(upstashHeader, newWorkflowRunId, ..., meta), with the invoker's meta
Invoke: headers the invoked run reports back with LazyInvokeStep.getBody() invokerHeaders carry it (the body already had workflowRunCreatedAt) originHeader via populateWorkflowHeaders(..., meta) in invoke.go (body has WorkflowCreatedAt)
Delete at the end of a run / context.cancel() triggerWorkflowDelete() sends the header when known. QStash then deletes/cancels the run only if its creation time matches, so a late delete of a previous run can't delete a new run with the same id deleteWorkflowRun(workflowRunId, meta.workflowCreatedAt, ...)
Notify, webhooks, sleep, failure callbacks unchanged unchanged

Differences from Go

  • handleThirdPartyCallResult is a JS-only legacy path; Go has no SDK-side call-result publish. The server now converts context.call results itself, so this path is mostly a fallback. It forwards the header so it can't reintroduce the deduplication problem.
  • Direct first invocation (the endpoint called without QStash workflow headers) has no Go equivalent. It goes through triggerFirstInvocation, and when the context has no creation time it falls back to Date.now(), the same rule Go uses for triggers. The fallback lives in the trigger rather than in serve, so context.workflowRunCreatedAt is never made up for a non-first request. Otherwise every retry of a step would send a different value and stop being deduplicated.
  • client.cancel() (the user API) is unchanged. The server's createdAt guard is only for an SDK deleting its own run.

Compatibility

The header is only sent when the creation time is known and non-zero. The server only includes it in the hash when present, so older servers still get the same requests. It should still be released after qstash-server#1129 is deployed.

Tests

New src/workflow-created-at.test.ts (14 tests):

  • getHeaders adds the header when set, and omits it when undefined, 0 or NaN
  • step, waitForEvent (request and timeout headers), context.call, and parallel steps (/v2/batch) carry it
  • trigger sends the run's creation time when known, and the trigger time (Date.now mocked) when not
  • delete and cancel send it, and omit it when unknown
  • the legacy third-party call result forwards it
  • serve end to end: an incoming request with the header → next step publish carries it; the finished workflow → delete carries it

Existing exact-header expectations were updated for trigger requests (client, serve, receiver, workflow-requests) and invoke requests (serve-many). The invoke tests now assert the invoked run is triggered with the invoker's creation time.

I checked the new tests against the source change reverted: 12 of 14 fail. The remaining 2 assert that the header is omitted.

bun test src: 419 pass. The 24 failures are the live tests that need QSTASH_TOKEN; they fail identically on main without a token. tsc, eslint, prettier --check and bun run build pass.

Worth verifying

In the server PR's singleton test, a new trigger for a run id that is still in progress returns 400 "a workflow already exists" (via /v2/trigger). Before this change, an identical client.trigger for an in-progress run could be deduplicated. With a fresh Date.now() per call it now reaches that check, and it may throw instead of returning "workflow-run-already-exists". client.trigger goes through /v2/batch, which the server tests don't cover, so this is worth checking against a server with #1129 deployed.

🤖 Generated with Claude Code

https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5

CahidArda and others added 3 commits September 30, 2026 07:37
… deduplicated

QStash includes Upstash-Workflow-CreatedAt in the deduplication of workflow
requests (upstash/qstash-server#1129). A run started again with the same run
id and the same data within the deduplication window was deduplicated
against the previous run, so it could not start or got stuck on its first
repeated step (e.g. singleton workflows).

- Every step, parallel step, wait (and its timeout message), context.call
  and third party call result carries the creation time of the run.
- A trigger carries the creation time of the run when it is known (second
  phase of the unknown SDK trigger), otherwise the trigger time.
- An invoke triggers the invoked run with the creation time of the invoker.
- The delete/cancel at the end of a run carries it, so QStash only deletes
  the run if it is the same run.

The header is not sent when the creation time is not known, so the requests
are unchanged against servers that don't set it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
- WorkflowConfig.workflowRunCreatedAt is required, so no request of a run
  can leave the creation time out by accident. 0 still means unknown and
  the header is not sent.
- A first invocation which doesn't come from QStash gets the trigger time
  as context.workflowRunCreatedAt in serve, which is the value the trigger
  sends. Other requests without the header keep 0: a value made up per
  request would differ between the retries of a step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
The runner starts a target workflow with client.trigger and a hardcoded
run id, waits, and checks in the logs that the run finished successfully
with all of its steps. Then it starts the target again with the same id
and the same data, and checks the second run the same way.

The target uses every kind of step in the context (run, parallel runs,
sleep, sleepUntil, call, waitForEvent notified and timed out, notify,
createWebhook, waitForWebhook, invoke with a hardcoded child run id), so
every kind of request is sent again with the same data by the second run.

Tests can now set a longer timeout in TEST_ROUTES; the results are waited
for during the extra time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
CahidArda and others added 4 commits September 30, 2026 13:34
Each retry also waits for a redis call, so counting a retry as one interval
made the retries of a 180s test outlast the vitest timeout, and a missing
result showed up as "Test timed out" instead of "no results found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
The request delivering a context.call result runs the route function until
the first step to authorize it, and that request has no payload, so the
check at the top failed the target on its call step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
The target run id is derived from the run id of the runner: the same in
every request of the runner, so both runs of the target still get the same
id, but different between test runs. The child run id and the event ids
are derived from the target run id the same way.

A test run no longer sees the runs of an earlier one, so the cancel of a
leftover run before the trigger is gone, and the first check expects
exactly one run of the id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
QStash reads the invoker headers of context.invoke and the timeout headers
of waitForEvent as they are, without canonicalizing the keys, so the
Upstash-Workflow-CreatedAt key was not found there (the canonical key is
Upstash-Workflow-Createdat, same as Upstash-Workflow-Runid for the run id).

The invoke result was then published without the creation time of the
invoker run, so the invoke result of a second run with the same id was
deduplicated against the one of the first run, and the second run never
continued after context.invoke. Found by running the same-run-id ci test
against a local QStash built from upstash/qstash-server#1129.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5
CahidArda and others added 2 commits October 1, 2026 14:54
Vercel no longer runs Node.js 18, and the project of this job was already
on Node.js 20, which Vercel has now discontinued too. The deploy step fails
before the test runs, and the deployed app hasn't run on Node.js 18 for a
while, so the job no longer tests what its name says.

Node.js 18 stays covered by nextjs12-node18-local-build, which builds the
SDK into the Next.js 12 example and runs its ci.mjs under Node.js 18 on
the runner.


Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Vercel discontinued Node.js 20, and on Node.js 24 the build failed in
@sveltejs/adapter-vercel 4, which only knows Node.js 18 and 20:

  Unsupported Node.js version: v24.21.0. Please use Node 18 or Node 20

adapter-vercel 6.3.4 supports nodejs20.x, 22.x and 24.x, and adapter-auto 7
uses it. Built with VERCEL=1 on Node.js 24.21.0: every function targets
nodejs24.x.


Claude-Session: https://claude.ai/code/session_017dm7F4hg2khgYDqBwP9nx5

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@CahidArda
CahidArda merged commit 694655d into main Oct 1, 2026
37 of 44 checks passed
@CahidArda
CahidArda deleted the feat/workflow-created-at-dedup branch October 1, 2026 12:37
CahidArda added a commit that referenced this pull request Oct 7, 2026
…ow-control

Resolves the conflicts with #214 (Upstash-Workflow-CreatedAt). Step config requests now carry the run's creation time too: QStash includes it in the deduplication key, so without it a step config request could be deduplicated against one from an earlier run with the same run id, stalling the new run. Adds a test pinning the header.
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.

2 participants