DX-2809: step-level flow control and retries via context.run().withSettings()#206
Open
CahidArda wants to merge 2 commits into
Open
DX-2809: step-level flow control and retries via context.run().withSettings()#206CahidArda wants to merge 2 commits into
CahidArda wants to merge 2 commits into
Conversation
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.
Summary
Adds step-level settings to workflow steps. Until now, flow control and retries could only be set when triggering the run (
client.trigger), and QStash applied them to every request of the run. This PR lets a single step override them:withSettingsmust be chained synchronously on thecontext.runcall. The settings override the trigger-level configuration for that step only; requests carrying them signal QStash with aWF_StepConfigfeature flag so they aren't clobbered by the run's trigger configuration (server support shipped separately).Architecture
A step's settings must ride on the request whose delivery executes that step. That request is created at different times depending on the previous step, so there are three mechanisms:
1. Deferred submission (step after
run/sleep/sleepUntil)Previously the auto executor executed a step, submitted its result and immediately threw
WorkflowAbort. Now (for steps whose result is available in-process) it executes the step, keeps the result as a pending submission and resolves the step's promise with the result, letting the workflow function continue. The nextcontext.*call reveals the next step — branching on the previous result works because the real result was returned (after a JSON round-trip, so the value is identical to what later replays observe). The pending result is then submitted with the next step's settings attached, and the usual abort is thrown.If the workflow function returns or throws before another step appears,
triggerRouteFunctionflushes the pending submission first. An error thrown after a step executed is swallowed for that invocation (it re-occurs deterministically on replay), guaranteeing a step never executes twice.Parallel steps carry their own settings on their plan steps, since a plan step's delivery is what executes its target step.
2. Discovery on the call callback (step after
context.call)The result of a
context.callis produced by QStash, so the SDK can't continue past the step at submission time. When the callback arrives, the SDK fetches the run's steps (getSteps), then runs the route function in a new discovery mode: memoized steps (including the fresh call result) replay as usual, and on reaching the first un-executed step the executor throws an internalWorkflowDiscoveryAbortcarrying that step's settings — without executing anything. The discovered settings are attached to the call-result submission the SDK performs anyway. Discovery is best-effort: any failure falls back to submitting without settings.3. Discovery + hidden redelivery (step after
context.invoke)The invoke result step is published by QStash directly, so the SDK marks the invoke submission with a forwarded header (
Upstash-Workflow-Invoke-Result: true) which comes back on the delivery carrying the invoke result. Seeing the marker,serveruns the discovery replay using the steps already in the delivery (no extra fetch):discoverycall type, and returns. QStash delivers it gated by the step-level flow control, and that delivery executes the step. QStash doesn't treat the helper request as a step and hides it from the step logs. The redelivery isn't marked, so there is no loop; its body carries the target step index so content-based deduplication doesn't collapse redeliveries of different steps.Limitations (fall back to trigger-level settings)
waitForEvent/waitForWebhook(could get the same marker treatment as invoke later)These are documented on the
StepSettingstype.Changes
context.runreturns aRunStepPromisewith the chainablewithSettingsmethod; settings are stored on the lazy step (StepSettingstype:flowControl,retries,retryDelay,timeout).AutoExecutor: pending-submission bookkeeping and flush, plus adiscoveryModewhich throwsWorkflowDiscoveryAbortinstead of executing fresh steps.submit-steps.tssplit intoexecuteStep/submitStepResult; settings headers (Upstash-Flow-Control-Key/Value,Upstash-Retries,Upstash-Retry-Delay,Upstash-Timeout+WF_StepConfigfeature) overlaid on submissions and parallel plan steps.handleThirdPartyCallResult: fetches the run steps once and runs next-step discovery before republishing the call result.serve: invoke-result marker handling andpublishStepSettingsRedelivery(newsrc/serve/discovery.ts).src/raw-steps.ts,recreateUserHeadersmoved toutils.ts.BaseStepLogfields.Requires the matching qstash-server change (same ticket) which makes QStash keep a message's own flow control/retries when the
WF_StepConfigfeature is present, and hidesdiscoverycall-type requests from step logs.Testing
triggerRouteFunction.bun test src: 394 pass.examples/ci/app/test-routes/flow-control/(step,call,invoke— see its README): each has anincrementstep with step-levelparallelism: 1which increments a shared Redis counter, holds it ~3s and decrements it. The test triggers 3 concurrent runs with permissive trigger-level flow control (parallelism: 5); every run asserts it never observed more than 1 concurrent execution. Verified against a local qstash-server: increments were serialized ~3.5s apart in all three variants, and negative controls (removing.withSettings) make the tests fail.BaseStepLog, discovery redeliveries absent from step logs.