Repository navigation
[world-local] Land a stream chunk after every chunk already in its stream - #4909
Conversation
…ream Signed-off-by: Pranit <pranit@vercel.com>
🦋 Changeset detectedLatest commit: 617f7d7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@fazxes is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
VaguelySerious
left a comment
There was a problem hiding this comment.
(AI) Preliminary review: AI review: no blocking issues
|
|
||
| // Without hard links a rename may replace a chunk of the same name, so the | ||
| // name is a new ULID unless the stream's newest is later. | ||
| async function renamedInto(dir: string, temp: string) { |
There was a problem hiding this comment.
AI Review: Note
Two things about the no-hard-links fallback:
- It looks unreachable in practice. Every event write already goes through
writeExclusive()infs.ts, whichlink()s a temp into place and has no fallback. On a file system without hard links, world-local fails at the run's first event, before any stream gets written. - It isn't safe across processes. Say process A lands
T|Rand B, in the same millisecond, listsT|Rand mintsT|R'withR' < R. B then takeschunkSuccessor(T|R). A's next write in that millisecond listsT|Rtoo, and its monotonic factory mintsT|R+1, which is the same name. Bothrename()intochnk_T|R+1, and the second one silently replaces the first's chunk. Before this PR, ids minted in different processes never collided like this.
I'd drop the fallback, along with mint and the monotonicUlid source, and let link() errors surface the way writeExclusive does. If you keep it, a comment noting the overwrite risk would help. Not blocking.
There was a problem hiding this comment.
AI Review: Note
Addressed in 4caefdc: the fallback is gone.
renamedInto,NO_HARD_LINKS, thehardLinksflag and themintoption are removed fromstream-chunk-place.ts, and themonotonicUlidsource (with itsulidand@workflow/utilsimports) is removed fromstreamer.ts.- A
link()that fails with anything other thanEEXIST, orENOENTonce the directory is recreated, now fails the write. This matcheswriteExclusive(). The temp file is still cleaned up. - The no-hard-links test is replaced by one where
link()fails once withENOTSUP: that write rejects, it leaves no temporary file, and the next write lands aschnk_0…02right afterchnk_0…01. - The module comment now says why there is no fallback.
world-local tests pass (679 passed, 4 skipped), tsc --noEmit is clean, and the multi-process SIGSTOP/SIGCONT stress run still holds: the live reader matches a read from 0, and indexes never move.
…d early; write chunk temps directly - A turn now ends only once the turns before it have, so a write that fails before it lands (a bad run id, a failed temp write) no longer lets a later write land ahead of an earlier one still waiting on its run id. - Chunk temporary files are written with a new `writeTempFile()` instead of `write()`: no extra temp-and-rename per chunk, and no entry per chunk in the never-pruned created-files cache. - `close()` forgets the stream's newest chunk id, so a streamer does not keep one entry per stream it ever wrote. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com>
e48c6f7 to
911ccb0
Compare
…ems without hard links The fallback renamed a chunk into place under a minted ULID, or the successor of the stream's newest id when that was later. Two processes renaming in the same millisecond could pick the same name, and the second rename silently replaced the first's chunk. It was also unreachable in practice: every event write already goes through `writeExclusive()`, which needs `link()` with no fallback. A link that fails for any reason other than EEXIST or a vanished directory now fails the write, and leaves no temporary file. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Peter Wielander <29887157+VaguelySerious@users.noreply.github.com>
4caefdc to
617f7d7
Compare
…ream (#4909) Previously, world-local named a stream chunk `chnk_<ULID>` when `streams.write()` is called, before any await, then writes it to a temporary file and renames that into place. Readers order a stream's chunks by name, and a live reader delivers each chunk it has not had after the ones it already delivered. So a write that stalls between the two, as one does when its process is frozen, or when its file write is slow, lands under a name that sorts ahead of every chunk other writers landed meanwhile: - a live reader that already had those chunks gets the late one after them; - every read from index 0 gets it before them, so each of those chunks moves one index on, and a reader that resumes from an index (`get(name, startIndex)`, a `getChunks` cursor) repeats one chunk or misses one. Two writers of one stream is what happens when a process stalls and another takes its work over. libfx runs durable sessions on world-local and numbers each line of a session's stream by its index, so a reader that reconnects from a line's cursor depends on indexes never moving. libfx's crash campaign on world-local hits this when it freezes a worker with a stream write on its way: the frozen worker's line lands ahead of lines the replacing worker wrote, which a reader in another process already had, and nine later lines change index. The new test below reproduces it without timing: held just before its rename, a chunk minted before two other writers' chunks lands ahead of them. This change names a chunk as it lands, in the new `src/stream-chunk-place.ts`: - **The name is the next one.** A chunk's name is the successor, in the ULID alphabet, of the stream's newest chunk id, and the chunk is linked into place with `link()`, which never replaces a file. The chunk lands only if no other chunk has that name, so it lands after every chunk already in the stream. - **A writer that stalled after naming its chunk takes the next name.** Its `link()` fails with `EEXIST` once another writer landed under that name. It lists the stream and tries after the newest. No lock is needed, and none would do: a lock held by a frozen process would hold every writer of the stream until it went stale, and the frozen holder would still land under its old name when it woke. - **One streamer's writes land in the order they were called,** as they did when each chunk was named then. Each write takes its turn in its stream before any await, and lands only after the turns before it, so the guarantee the promise `runId` test pins is kept. `writeMulti` still writes its chunks in parallel and lands them in order. - **No listing in the common case.** A streamer remembers the last chunk it landed in each stream and tries the next name first. It lists the stream for its first write to it and after a failed link. - **A stream's first chunk is `chnk_0…01`,** the successor of the tombstone a zero-retention purge leaves, which still sorts first. - **A file system that makes no hard links** (`link()` fails with `ENOTSUP`, `EOPNOTSUPP`, `ENOSYS`, `EXDEV`, or `EPERM` outside Windows) renames the chunk into place as before, under a name minted as it lands. That narrows the window from the whole write to the moment between naming and renaming. Signed-off-by: Pranit <pranit@vercel.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Pranit <pranit@vercel.com>
|
Backport PR opened against |
…lready in its stream (#5188) Automated backport of #4909 to `stable` ([backport job run](https://github.com/vercel/workflow/actions/runs/41509522981)). Signed-off-by: Pranit <pranit@vercel.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com>
### Description Docs follow-ups for user-facing changes merged on Nov 2 that shipped without docs on these pages: - **CLI and Web UI**, **Vercel World**: `--project` (ID or slug) and `--team` take precedence over the `.vercel` link, and in a monorepo the CLI uses the nearest `.vercel/project.json` (#5182). - **`createWebhook`**: outside Vercel, `url` uses `PORT`, then the detected server port (#5184). - **Sequential and parallel cookbook**: says `Promise.race` behaves as in plain JavaScript. #5187 is being reverted, so it isn't documented here. #5100 is a bug fix that makes `Promise.race` behave as users expect, so it needs no docs beyond the cookbook wording. #5183, #5189, #5190 and #4909 don't change anything users do. ### Docs Preview - [CLI and Web UI: `--project`](https://workflow-docs-git-workflow-docs-cleanup-tue-3-nov.vercel.sh/docs/configuration/cli-and-web-ui#--project) - [Vercel World: Observability](https://workflow-docs-git-workflow-docs-cleanup-tue-3-nov.vercel.sh/worlds/vercel#observability) - [createWebhook](https://workflow-docs-git-workflow-docs-cleanup-tue-3-nov.vercel.sh/docs/api-reference/workflow/create-webhook) - [Sequential and parallel: How it works](https://workflow-docs-git-workflow-docs-cleanup-tue-3-nov.vercel.sh/cookbook/common-patterns/sequential-and-parallel#how-it-works) ### PR Checklist - Required to merge - [x] 🔒 DCO sign-off included on every commit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to stable, this PR will be updated. # Releases ## @workflow/world-postgres@4.4.0 ### Minor Changes - [#4759](#4759) [`3ff458e`](3ff458e) Thanks [@pranaygp](https://github.com/pranaygp)! - Add `jobLockStaleSeconds` (`WORKFLOW_POSTGRES_JOB_LOCK_STALE_SECONDS`, default `0`, off). When set, a running delivery renews its Graphile job lock, and a job whose process died is redelivered by another process once its lock has gone unrenewed for that many seconds, instead of after Graphile Worker's fixed 4 hours or the next restart. `30` is a good value when more than one process shares the database. It's off by default because of rolling upgrades: processes on an earlier version don't renew their locks, so turn it on once every process that shares the database runs this version. ### Patch Changes - [#5136](#5136) [`5c9b65d`](5c9b65d) Thanks [@alangenfeld](https://github.com/alangenfeld)! - A `run_started` that races the run ending no longer moves the run back to `running`; it's refused with `RunExpiredError`. - Updated dependencies [[`ceaaa5c`](ceaaa5c), [`b065b0b`](b065b0b)]: - @workflow/world-local@4.4.5 ## @workflow/astro@4.0.31 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 - @workflow/rollup@4.0.31 - @workflow/vite@4.0.31 ## @workflow/builders@4.1.21 ### Patch Changes - [#5063](#5063) [`e4dbc29`](e4dbc29) Thanks [@joelfickson](https://github.com/joelfickson)! - Export `workflowSerdeComputedPropertyPattern` alongside the other workflow pattern regexes. - Updated dependencies []: - @workflow/core@4.8.16 ## @workflow/cli@4.3.20 ### Patch Changes - [#5182](#5182) [`f3d6142`](f3d6142) Thanks [@VaguelySerious](https://github.com/VaguelySerious)! - Respect `--project` and `--team` over the inferred `.vercel` link, and prefer the nearest `.vercel/project.json` in monorepos - Updated dependencies [[`e4dbc29`](e4dbc29), [`dbfd1ba`](dbfd1ba), [`ceaaa5c`](ceaaa5c), [`b065b0b`](b065b0b)]: - @workflow/builders@4.1.21 - @workflow/web@4.1.32 - @workflow/world-local@4.4.5 - @workflow/core@4.8.16 ## @workflow/core@4.8.16 ### Patch Changes - Updated dependencies [[`ceaaa5c`](ceaaa5c), [`b065b0b`](b065b0b)]: - @workflow/world-local@4.4.5 ## @workflow/nest@4.0.32 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 ## @workflow/next@4.1.20 ### Patch Changes - [#5063](#5063) [`e4dbc29`](e4dbc29) Thanks [@joelfickson](https://github.com/joelfickson)! - Run the Turbopack workflow loader on files that use `static [WORKFLOW_SERIALIZE]` computed keys (such as `@ai-sdk/openai` models), fixing "Class … not found" errors in steps. - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 - @workflow/core@4.8.16 ## @workflow/nitro@4.1.22 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29), [`dbfd1ba`](dbfd1ba)]: - @workflow/builders@4.1.21 - @workflow/web@4.1.32 - @workflow/rollup@4.0.31 - @workflow/vite@4.0.31 - @workflow/core@4.8.16 ## @workflow/nuxt@4.0.32 ### Patch Changes - [#5211](#5211) [`d30f07e`](d30f07e) Thanks [@atinux](https://github.com/atinux)! - Upgrade `@nuxt/kit` to 4.6.3 to pick up the fix for CVE-2026-71314. - Updated dependencies []: - @workflow/nitro@4.1.22 ## @workflow/rollup@4.0.31 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 ## @workflow/sveltekit@4.0.31 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 - @workflow/rollup@4.0.31 - @workflow/vite@4.0.31 ## @workflow/vite@4.0.31 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29)]: - @workflow/builders@4.1.21 ## @workflow/vitest@4.0.32 ### Patch Changes - Updated dependencies [[`e4dbc29`](e4dbc29), [`ceaaa5c`](ceaaa5c), [`b065b0b`](b065b0b)]: - @workflow/builders@4.1.21 - @workflow/world-local@4.4.5 - @workflow/rollup@4.0.31 - @workflow/core@4.8.16 ## @workflow/web@4.1.32 ### Patch Changes - [#5079](#5079) [`dbfd1ba`](dbfd1ba) Thanks [@karthikscale3](https://github.com/karthikscale3)! - Recognize unframed serialized abort packets in the Streams viewer so encrypted packets can be decrypted. ## @workflow/web-shared@4.1.32 ### Patch Changes - Updated dependencies []: - @workflow/core@4.8.16 ## workflow@4.8.16 ### Patch Changes - Updated dependencies [[`f3d6142`](f3d6142), [`d30f07e`](d30f07e), [`e4dbc29`](e4dbc29)]: - @workflow/cli@4.3.20 - @workflow/nuxt@4.0.32 - @workflow/next@4.1.20 - @workflow/astro@4.0.31 - @workflow/nest@4.0.32 - @workflow/nitro@4.1.22 - @workflow/rollup@4.0.31 - @workflow/sveltekit@4.0.31 - @workflow/core@4.8.16 - @workflow/typescript-plugin@4.0.3 ## @workflow/world-local@4.4.5 ### Patch Changes - [#5069](#5069) [`ceaaa5c`](ceaaa5c) Thanks [@AndrewBarba](https://github.com/AndrewBarba)! - Deliver stream chunks in the order they were written. A reader that opened, or polled, while chunks were being written could get a later chunk before an earlier one, and could get a chunk twice. Writes to one stream now land in order, including a `writeToStreamMulti` group and concurrent writes, and the reader holds live chunks while it reads from disk. - [#4909](#4909) [`b065b0b`](b065b0b) Thanks [@fazxes](https://github.com/fazxes)! - Name each stream chunk as it lands, after every chunk already in the stream, so a write that stalled before landing no longer lands ahead of chunks a reader already read. ## @workflow/world-testing@4.1.31 ### Patch Changes - Updated dependencies [[`f3d6142`](f3d6142)]: - @workflow/cli@4.3.20 - workflow@4.8.16 - @workflow/core@4.8.16 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Description
world-local names a stream chunk
chnk_<ULID>whenstreams.write()is called, before any await, then writes it to a temporary file and renames that into place. Readers order a stream's chunks by name, and a live reader delivers each chunk it has not had after the ones it already delivered. So a write that stalls between the two, as one does when its process is frozen, or when its file write is slow, lands under a name that sorts ahead of every chunk other writers landed meanwhile:get(name, startIndex), agetChunkscursor) repeats one chunk or misses one.Two writers of one stream is what happens when a process stalls and another takes its work over. libfx runs durable sessions on world-local and numbers each line of a session's stream by its index, so a reader that reconnects from a line's cursor depends on indexes never moving. libfx's crash campaign on world-local hits this when it freezes a worker with a stream write on its way: the frozen worker's line lands ahead of lines the replacing worker wrote, which a reader in another process already had, and nine later lines change index. The new test below reproduces it without timing: held just before its rename, a chunk minted before two other writers' chunks lands ahead of them.
This change names a chunk as it lands, in the new
src/stream-chunk-place.ts:link(), which never replaces a file. The chunk lands only if no other chunk has that name, so it lands after every chunk already in the stream.link()fails withEEXISTonce another writer landed under that name. It lists the stream and tries after the newest. No lock is needed, and none would do: a lock held by a frozen process would hold every writer of the stream until it went stale, and the frozen holder would still land under its old name when it woke.runIdtest pins is kept.writeMultistill writes its chunks in parallel and lands them in order.chnk_0…01, the successor of the tombstone a zero-retention purge leaves, which still sorts first.link()fails withENOTSUP,EOPNOTSUPP,ENOSYS,EXDEV, orEPERMoutside Windows) renames the chunk into place as before, under a name minted as it lands. That narrows the window from the whole write to the moment between naming and renaming.Why a landed chunk always sorts after every chunk already in the stream: every name a writer creates is the successor of a name that existed, or the first id, so the names after a stream's older chunks have no gap. A
link()to the successor of the id a writer knows succeeds only while that successor is free, and with no gap, that means the id it knows is the stream's newest. A writer whose id is out of date fails and lists the stream. Each failure moves its id forward, so the loop ends.Compatibility:
chnk_and 26 ULID characters, sorted as text, and a stream written by an older world-local goes on after its newest chunk.streamer.test.tsdecodes it only to print the chunks when a test fails.Out of scope:
fs.readdirlists a large directory in several system calls, so a reader whose listing runs while two chunks land can see the second without the first, and deliver them out of order. That needs a change on the reader side (wait for the next poll when the next name is not the successor of the last), and is the same before and after this change.How did you test your changes?
src/stream-chunk-place.test.ts, 7 tests:chunkSuccessorcounts up in the ULID alphabet and carries, and refuses what is not a chunk id and the last one;newestChunkIdreads the newest chunk of any tag or format and ignores temporary and other files;link()that lands it, or therename()that did before this change) lands after the chunks another writer landed meanwhile, the other writer took the name it chose, and a reader in its own streamer gets what a read from index 0 gets;src/stream-chunk-place.child.mjs, run with Node's type stripping likein-band-fence.child.mjs), each writing 40 chunks to one stream, land every chunk once, each writer's in its order, and a live reader gets them in the order a read from index 0 does;runIdpromise and a batch whose first chunk is the largest, and the stream's first chunk ischnk_0…01;streamer.tsback at 5.1.1, the four placement tests fail. The stalled chunk is read before the other writer's:[ 'a1', 'a2', 'b1', 'b2', 'b3' ]where[ 'a1', 'b1', 'b2', 'a2', 'b3' ]is expected. The live reader of the racing processes disagrees with a read from 0. A write lands before the one called earlier. Andlinkis never called.vitest run srcinpackages/world-local: 28 files, 678 tests pass on 5.1.1 with the change, and 682 onmainwith it.tsc --noEmitpasses.biome checkon the changed files reports only the three existing complexity warnings instreamer.ts.write()takes about 0.6 ms instead of 0.3 ms (thelink(), theunlink()of the temporary name, andwrite()'s own temporary rename), and a streamer's first write to a stream of 2,000 chunks lists it in 2 to 4 ms.PR Checklist - Required to merge
pnpm changesetwas run to create a changelog for this PRpatchfor bug fixes,minorfor new features,majorfor breaking changes.pnpm changeset --emptyif you are changing documentation or workbench appsgit commit --signoffon your commits)@vercel/workflowin a comment once the PR is ready, and the above checklist is complete