Repository navigation
feat: send Upstash-Workflow-CreatedAt so runs with the same id aren't deduplicated - #214
Merged
Merged
Conversation
… 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
force-pushed
the
feat/workflow-created-at-dedup
branch
from
September 30, 2026 12:36
23aed1d to
202b76b
Compare
CahidArda
added this pull request to stack #216
September 30, 2026 12:58
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
sancar
approved these changes
Oct 1, 2026
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
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.
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.
Why
upstash/qstash-server#1129 (QSTH-1101) adds
Upstash-Workflow-CreatedAtto 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
WorkflowConfiggets an optionalworkflowRunCreatedAt.getHeaders(src/qstash/headers.ts) addsUpstash-Workflow-CreatedAtas a raw header when it is set and non-zero. Everything else passescontext.workflowRunCreatedAtinto it. The context already reads it from the incoming request (src/serve/index.ts). This is the one-helper approach the Go SDK uses withpopulateWorkflowHeaders.Mapping to the Go SDK (qstash-server#1129)
WorkflowHeaders.addBaseHeaders()setsUpstash-Workflow-CreatedAtwhenworkflowConfig.workflowRunCreatedAtis set (src/qstash/headers.ts)populateWorkflowHeaders()setsCreatedAtHeaderwhenmeta.workflowCreatedAt != 0(workflow/workflow.go)workflowRunCreatedAtfrom the header insrc/serve/index.tsparseIncomingRequestMeta()reads it intometa.workflowCreatedAtBaseLazyStep.getHeaders()(src/context/steps.ts)callPublishForNextStep()→populateWorkflowHeaders(..., meta)submitParallelSteps()(src/qstash/submit-steps.ts)populateWorkflowHeaderspathwaitForEvent+ its timeout messageLazyWaitEventStepuses the step headers for the/v2/waitrequest, andtimeoutHeadersis built from themwaitnotify.go:populateWorkflowHeaderson both the wait headers andtimeoutHeaderscontext.callLazyCallStepinherits the step headers, so the call publish carries itcallCallbackForNextStep()→populateWorkflowHeaders(..., meta)context.callresultworker/workflow_call.go); nothing needed in the SDK. Legacy fallbackhandleThirdPartyCallResult()copies the incoming header onto the result it publishes (src/workflow-requests.ts)triggerFirstInvocation()sendsworkflowRunCreatedAt || Date.now().client.triggeralready setsDate.now()once per call, so retries of the same request carry the same value and are still deduplicatedpopulateWorkflowHeadersTrigger():options.workflowCreatedAt, ortime.Now().UnixMilli()when 0triggerFirstInvocation({ unknownSdk: true })gets the run's creation time from the incoming request through the contextTriggerOptions{..., workflowCreatedAt: meta.workflowCreatedAt}on themeta.unknownSdkpathLazyInvokeStep.getHeaders()sends the invoker'scontext.workflowRunCreatedAtcallInvokeWorkflow()→populateWorkflowHeaders(upstashHeader, newWorkflowRunId, ..., meta), with the invoker's metaLazyInvokeStep.getBody()invokerHeaderscarry it (the body already hadworkflowRunCreatedAt)originHeaderviapopulateWorkflowHeaders(..., meta)ininvoke.go(body hasWorkflowCreatedAt)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 iddeleteWorkflowRun(workflowRunId, meta.workflowCreatedAt, ...)Differences from Go
handleThirdPartyCallResultis a JS-only legacy path; Go has no SDK-side call-result publish. The server now convertscontext.callresults itself, so this path is mostly a fallback. It forwards the header so it can't reintroduce the deduplication problem.triggerFirstInvocation, and when the context has no creation time it falls back toDate.now(), the same rule Go uses for triggers. The fallback lives in the trigger rather than inserve, socontext.workflowRunCreatedAtis 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):getHeadersadds the header when set, and omits it when undefined, 0 or NaNwaitForEvent(request and timeout headers),context.call, and parallel steps (/v2/batch) carry itDate.nowmocked) when notserveend to end: an incoming request with the header → next step publish carries it; the finished workflow → delete carries itExisting 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 thelivetests that needQSTASH_TOKEN; they fail identically onmainwithout a token.tsc,eslint,prettier --checkandbun run buildpass.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 identicalclient.triggerfor an in-progress run could be deduplicated. With a freshDate.now()per call it now reaches that check, and it may throw instead of returning"workflow-run-already-exists".client.triggergoes 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