You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Derive the workflow VM's deterministic RNG seed from runId:workflowName:deploymentId (dropping the startedAt timestamp component) and its initial fixed clock from the ULID timestamp embedded in runId.
All of these inputs are available the instant a queue message arrives, so VM seed/clock setup no longer depends on startedAt (which is only known after the run_started round-trip).
This is the prerequisite for a follow-up that physically starts VM initialization earlier on the critical path (before run_started).
Details
packages/core/src/runtime/run-id-time.ts (new): runIdCreatedAt(runId) decodes the creation time from a wrun_<ulid> run ID, returning undefined for non-ULID ids (e.g. test fixtures) so callers fall back to the run snapshot's createdAt.
packages/core/src/workflow.ts: seed is now runId:workflowName:deploymentId; initial fixedTimestamp comes from runIdCreatedAt(runId) ?? +workflowRun.createdAt. startedAt is still used for generateUlid time and the workflowStartedAt metadata exposed to user code.
The initial fixedTimestamp only governs Date.now()/new Date() in the window before the first event is consumed; updateTimestamp then advances the VM clock to each consumed event's createdAt, starting with run_created.
Behavior change / migration note
Changing the seed formula changes every seed-derived value (step/hook correlation ID ULIDs, nanoids, crypto.getRandomValues, randomUUID) for a given run. Runs started before this change must not be replayed across the upgrade — they would diverge. Acceptable on the beta channel.
Testing
Regenerated the affected deterministic test fixtures by computing the exact old→new correlation IDs from the seeded ULID factory, and switched seed-deriving tests to the new runId:workflowName:deploymentId formula.
New unit tests for runIdCreatedAt.
All @workflow/coresrc/ tests pass (1251). The only failing suites are the pre-existing environment-dependent e2e/ tests (fail identically on main).
Changeset
@workflow/core: minor (includes the in-flight-replay migration note)
Follow-up
The structural change — splitting runWorkflow into prepare/execute and kicking off context creation before run_started — is intentionally left as a focused follow-up so it can be benchmarked in isolation. This PR is the foundation that unblocks it.
Derive the deterministic RNG seed from `runId:workflowName:deploymentId`
and the VM's initial fixed clock from the ULID timestamp embedded in
`runId` (via the new `runIdCreatedAt` helper). All of these inputs are
available the instant a queue message arrives, so the VM seed and clock
no longer depend on `startedAt` (set only after the `run_started`
round-trip). This is the prerequisite for starting VM initialization
earlier on the critical path.
This changes the seed-derived value sequence for a given run, so the
affected deterministic test fixtures are regenerated accordingly.
The reason will be displayed to describe this comment to others. Learn more.
Reviewed the seed/clock decoupling closely — the core change is correct and replay-safe:
Seed stability:seedrandom(seed) in vm/index.ts is the only RNG input (fixedTimestamp only drives Date), so changing the seed source doesn't perturb the clock and vice-versa. The new seed runId:workflowName:deploymentId is replay-stable: runId is the ULID (fixed), and deploymentId is read from the persisted run snapshot (a required z.string()), not process.env.VERCEL_DEPLOYMENT_ID — so it's the creation-time pinned deployment and is identical on every replay, even if execution somehow lands on a newer deployment. runId alone already guarantees uniqueness, as the comment notes.
Clock stability:fixedTimestamp = runIdCreatedAt(runId) ?? +createdAt. createdAt is a required z.coerce.date(), so the fallback can't be NaN. Good call using ?? rather than || — a decoded epoch of 0 won't wrongly trigger the fallback. The branch taken depends only on runId, so it's deterministic per run. Net effect is also a small improvement: the pre-first-event clock now starts at ~createdAt and advances monotonically to run_started, instead of jumping backward from startedAt.
The seed formula is reconstructed nowhere else in non-test code (no server/step-executor copy to drift), and the regenerated fixtures keep the same 01HK153X00 time prefix with only the random tail changing — exactly consistent with timestamp-from-startedAt / randomness-from-seed. CI Unit Tests pass on ubuntu + windows, and the dynamic self-validating test (workflow.test.ts:5188) derives IDs from the new formula.
The reason will be displayed to describe this comment to others. Learn more.
Optional (non-blocking): this re-implements "strip a prefix, then decode the ULID time" that already lives in @workflow/world as ulidToDate() (and validateUlidTimestamp() does the exact prefix-strip + decode). @workflow/core already depends on @workflow/world and imports values from it (e.g. SPEC_VERSION_SUPPORTS_COMPRESSION in workflow.ts), and ulidToDate is exported from the package root, so this could fold into:
Bonus: ulidToDate validates via z.string().ulid() (stricter than a bare decodeTime in try/catch) and returns null for non-ULIDs, which ?.getTime() turns into undefined — same contract you have now. Reasonable to keep it self-contained in core if you'd rather not couple the early-init path to the world util, but worth a look.
Derive the deterministic RNG seed from `runId:workflowName:deploymentId`
and the VM's initial fixed clock from the ULID timestamp embedded in
`runId` (via the new `runIdCreatedAt` helper). All of these inputs are
available the instant a queue message arrives, so the VM seed and clock
no longer depend on `startedAt` (set only after the `run_started`
round-trip). This is the prerequisite for starting VM initialization
earlier on the critical path.
This changes the seed-derived value sequence for a given run, so the
affected deterministic test fixtures are regenerated accordingly.
Signed-off-by: Nathan Rajlich <n@n8.io>
…testing
* origin/main:
perf(core): decouple workflow VM seed/clock from startedAt (#2525)
[world-local] [core] Cache local dev server port per process (#2522)
Show pending runs as gray animated stripes in trace viewer (#2520)
[world-vercel] Route v4 event requests through global fetch (#2514)
[core] Send workflowName with step events (#2511)
Stamp run IDs on world spans (#2508)
Reject empty-string hook tokens in createHook() (#2490)
perf(core): cache compiled workflow-bundle vm.Script across replays (#2471)
perf(core): drain consumable replay events synchronously (#2473)
perf(core): lazy inline step start (save one world round-trip per step) (#2478)
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
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
runId:workflowName:deploymentId(dropping thestartedAttimestamp component) and its initial fixed clock from the ULID timestamp embedded inrunId.startedAt(which is only known after therun_startedround-trip).run_started).Details
packages/core/src/runtime/run-id-time.ts(new):runIdCreatedAt(runId)decodes the creation time from awrun_<ulid>run ID, returningundefinedfor non-ULID ids (e.g. test fixtures) so callers fall back to the run snapshot'screatedAt.packages/core/src/workflow.ts: seed is nowrunId:workflowName:deploymentId; initialfixedTimestampcomes fromrunIdCreatedAt(runId) ?? +workflowRun.createdAt.startedAtis still used forgenerateUlidtime and theworkflowStartedAtmetadata exposed to user code.The initial
fixedTimestamponly governsDate.now()/new Date()in the window before the first event is consumed;updateTimestampthen advances the VM clock to each consumed event'screatedAt, starting withrun_created.Behavior change / migration note
Changing the seed formula changes every seed-derived value (step/hook correlation ID ULIDs, nanoids,
crypto.getRandomValues,randomUUID) for a given run. Runs started before this change must not be replayed across the upgrade — they would diverge. Acceptable on the beta channel.Testing
runId:workflowName:deploymentIdformula.runIdCreatedAt.@workflow/coresrc/tests pass (1251). The only failing suites are the pre-existing environment-dependente2e/tests (fail identically onmain).Changeset
@workflow/core: minor (includes the in-flight-replay migration note)Follow-up
The structural change — splitting
runWorkflowinto prepare/execute and kicking off context creation beforerun_started— is intentionally left as a focused follow-up so it can be benchmarked in isolation. This PR is the foundation that unblocks it.