-
Notifications
You must be signed in to change notification settings - Fork 345
fix(world-vercel,world-local): hold process-wide state on globalThis #3728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d581e50
6141eee
6aa5f7a
a632b84
316a473
2b9bc91
3c628ba
c87dea3
00338ea
6b991a5
c95c5ca
9979bb5
cdda2ca
afa0302
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/utils': minor | ||
| --- | ||
|
|
||
| Add `globalSingleton()`, which parks a package's process-wide state on `globalThis` so bundled copies of a module in one process share it. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/world-testing': patch | ||
| --- | ||
|
|
||
| Annotate the test server's per-run invocation counter as deliberately per-copy, so it passes the module-scope state rule. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| '@workflow/world': patch | ||
| '@workflow/ai': patch | ||
| '@workflow/nest': patch | ||
| --- | ||
|
|
||
| Hold process-wide state on `globalThis` rather than at module scope in the packages that get bundled into the host application's server build, where a bundler compiles one copy of each module per layer. Covers warn-once latches, lazy caches, the VM script and QuickJS asset caches, the dev-server port cache, and step single-flight, whose per-copy map was not actually single-flight. State that is deliberately per-copy is annotated with the reason. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| --- | ||
|
|
||
| Build the workflow entrypoint's queue handler from the runtime World (`getWorld()`) instead of `getWorldHandlers()`, so a process creates one World rather than two. A stateful World no longer gets duplicate connection pools or queue workers. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@workflow/utils': patch | ||
| --- | ||
|
|
||
| Declare `sideEffects: false` so bundlers can drop the unused parts of the barrel from a host application's build. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@workflow/world-vercel': patch | ||
| '@workflow/world-local': patch | ||
| --- | ||
|
|
||
| Hold process-wide state (the WebSocket transport registry, HTTP connection pools, ULID factories, caches, log-once latches) on `globalThis` instead of at module scope. This de-duplicates state across bundled packages. Fixes WebSocket transport, which was registered in one module state but looked up in another. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -373,6 +373,65 @@ If you implement this namespace, observe the following requirements: | |
|
|
||
| See the [Analytics API reference](/docs/api-reference/workflow-runtime/world/analytics) for per-method parameters, row shapes, and `pageInfo` semantics. | ||
|
|
||
| ## Process-wide state | ||
|
|
||
| Hold state that must be process-wide on `globalThis`, not at module scope. | ||
|
|
||
| A World is loaded in one of two ways, and only one of them gives your package a | ||
| single module instance: | ||
|
|
||
| - **Loaded at runtime.** `WORKFLOW_TARGET_WORLD=@your-org/world-foo` is resolved | ||
| with `require()` at runtime, so Node's module cache dedupes it and one process | ||
| holds one copy. | ||
| - **Bundled.** The host application's bundler compiles your package into its | ||
| server build. Bundlers key module identity on `(resource, layer)`, and a | ||
| framework routinely builds several server layers. Next.js compiles | ||
| `instrument`, app-route, `ssr` and `edge` as separate module graphs. Your | ||
| package is then compiled into each one, so a single process holds several | ||
| copies of every one of your modules, each with its own module scope. | ||
|
|
||
| The two built-in worlds are bundled. A custom world is not today, but that is a | ||
| property of how it is loaded rather than of how it is written, and it can change | ||
| under you. `@workflow/world-vercel` was external until it wasn't, and every | ||
| module-scope variable in it silently became per-copy state. | ||
|
|
||
| So a top-level `let` or a `const` holding a `Map` is not the singleton it looks | ||
| like: | ||
|
|
||
| ```typescript | ||
| // Wrong: one Map per copy. Writes from one part of the app are invisible to | ||
| // another, and a mutex like this simply stops mutually excluding. | ||
| const locks = new Map<string, Promise<void>>(); | ||
| ``` | ||
|
|
||
| Reach for `globalThis` under a `Symbol.for()` key instead, so every copy shares | ||
| one object: | ||
|
|
||
| ```typescript | ||
| type WorldState = { locks: Map<string, Promise<void>> }; | ||
|
|
||
| const StateKey = Symbol.for('@your-org/world-foo//locks/v1'); | ||
| const store = globalThis as typeof globalThis & | ||
| Record<symbol, WorldState | undefined>; | ||
|
|
||
| const state: WorldState = (store[StateKey] ??= { locks: new Map() }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: BlockingThis snippet fails the lint rule added in this same PR. The rule accepts exactly two things: a
Either teach
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed, and I took the second option you suggested — the rule now recognizes state rooted at You were right that it's worth doing on its own: I also did the first thing, in a smaller way: the docs section now says outright that |
||
| ``` | ||
|
|
||
| Version the key. Two releases of your package can end up in one process, and a | ||
| key without a version lets an older copy read a state object it does not | ||
| understand. | ||
|
|
||
| Inside this repository, `globalSingleton()` from `@workflow/utils` does exactly | ||
| this and is what the first-party worlds use; the hand-rolled form above is | ||
| written out so a world published outside this repository does not need the | ||
| dependency. `scripts/lint/module-scope-state.mjs` accepts either. | ||
|
|
||
| Better still, keep the state on the World instance your `createWorld()` returns. | ||
| Connection pools, caches, and open channels are usually per-World rather than | ||
| per-process, and instance state cannot be duplicated by a bundler. Reserve the | ||
| global for the few things that are genuinely process-wide: ID generators whose | ||
| sequence must not fork, and log-once latches. | ||
|
|
||
| ## Reference implementations | ||
|
|
||
| Study these implementations for guidance: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Note
Worth a sentence here on why the rule stops at
packages/world-*.@workflow/coreis statically imported into the same server build and has always been bundled, and this PR's own reasoning (a run started from a Server Component and consumed in a route handler puts live copies in both thessrand app-route graphs) applies to it unchanged. It reports 26:I spot-checked several and they look wasteful rather than wrong:
registeredSteps(private.ts:28) is already globalThis-backed, the compile and single-flight caches are only reached from/flowso they stay in one layer, and the duplicated cbor encoders and warn-once latches cost memory and a repeated log. So I am not asking for core in this PR. But as written, someone reading "enforces this across every publishedpackages/world-*" alongside "the class of bug behind it" in the PR description will assume core is covered.packages/nextreports 7 andpackages/cli4 for the same reason.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added. AGENTS.md now states where the sweep stops and why, rather than leaving 'every published
packages/world-*' to be read as covering core.I used your spot-check verbatim, since it's more useful than a bare caveat: the step registry is already globalThis-backed, the compile and single-flight caches are only reached from
/flowso they stay in one layer, and the remainder cost a duplicated encoder or a repeated warn-once log — wasteful rather than wrong, which is why core isn't gated.@workflow/nextand@workflow/clinoted as the same case. Widening the sweep is tracked in #3729, and the note ends by saying a new mutable module-scope binding in core should be treated as suspect even though nothing fails the build.The globalThis fix from your other comment also brings core down to 22 and next to 6, so those numbers are now closer to the genuine ones.