Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/sealed-log-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@workflow/world': patch
'@workflow/world-vercel': patch
'@workflow/world-local': patch
'@workflow/world-postgres': patch
'@workflow/core': patch
---

New runs are no longer created with the sealed-log event identity (specVersion 7) by default; set `WORKFLOW_SEALED_LOG=1` to opt in. Every runtime still reads sealed logs, and a run's version is fixed at creation, so runs already created at specVersion 7 keep working.
10 changes: 5 additions & 5 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,12 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL

### `WORKFLOW_SEALED_LOG`

- Default: enabled
- New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap.
- Default: disabled
- Set `1` to create new runs at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap.
- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow or advancing the deterministic clock. Its timestamp belongs to whichever reader sealed it, not to the run.
- Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems.
- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting.
- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Switch this off in an environment where it has not.
- Left off, a deployment stays on the previous scheme, where each position is allocated by the write that occupies it. That is the default while two things remain outstanding: an abandoned claim currently strands its run between a step outcome and the resume that should follow it, recovered only by the queue's own redelivery some minutes later, and the Python runtime cannot yet read a sealed log at all.
- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting, so runs created while it was on stay readable after it goes off.
- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Leave this off in any environment that serves one.
- Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec.

### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS`
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/v5/how-it-works/event-sourcing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ The observability UI grays out events it can identify this way and shows the rea

## Sealed positions (noop events)

Runs at `specVersion` 7 and above use a *sealed log*. The backend gives each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race for a slot. New runs use this behavior by default. [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) returns a deployment to the previous scheme. Every runtime reads a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects an in-flight run. However, a writer can claim a position and then stop because of a crashed process or canceled transaction. This leaves a hole that no writer will fill, and a hole looks like an event the reader failed to load.
Runs at `specVersion` 7 and above use a *sealed log*. The backend gives each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race for a slot. New runs opt in to this behavior with [`WORKFLOW_SEALED_LOG=1`](/docs/configuration/runtime-tuning#workflow_sealed_log); left unset, they stay on the previous scheme. Every runtime reads a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects an in-flight run. However, a writer can claim a position and then stop because of a crashed process or canceled transaction. This leaves a hole that no writer will fill, and a hole looks like an event the reader failed to load.

The backend restores the dense log at read time by **sealing** these positions. Once a hole is provably abandoned, bounded by the commit time of later positions, the backend writes a `noop` event into it. Positions are assigned in order, so a committed later position proves how long the hole has been open. A `noop` occupies its position, and length-based completeness checks, cursors, and pagination all count it. It has no other effect:

Expand Down
2 changes: 1 addition & 1 deletion docs/content/worlds/v5/building-a-world.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ Spec version 7 supports one alternative to allocate-at-commit for Worlds whose s

The runtime skips `noop` events during replay. It never delivers them to a consumer or uses them to advance the deterministic clock, so a sealed log replays identically to one whose writers filled the holes. `noop` isn't user-creatable and is never sent to `events.create()`. Only your read path may write one. Worlds that allocate at the commit, through a synchronous counter or unique-constraint append, maintain perfect density and don't need sealing. `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec 7 compliant without additional work.

The version a World stamps comes from `mintedSpecVersion()`: 7 by default or the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) disables it. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment.
The version a World stamps comes from `mintedSpecVersion()`: the slot-identity version by default, or 7 when [`WORKFLOW_SEALED_LOG=1`](/docs/configuration/runtime-tuning#workflow_sealed_log) opts in. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment.

`events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel.

Expand Down
13 changes: 10 additions & 3 deletions packages/world-testing/src/event-ids.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
eventIdToSlot,
FIRST_EVENT_SLOT,
SPEC_VERSION_CURRENT,
mintedSpecVersion,
SPEC_VERSION_MAX_SUPPORTED,
slotToEventId,
} from '@workflow/world';
Expand Down Expand Up @@ -43,9 +43,16 @@ export function eventIds(world: string) {
// what the runtime checks before it replays anything. A World that numbers
// its events correctly while declaring an older version is rejected at
// startup, which reads as a broken install rather than as a stale
// constant. Declaring `SPEC_VERSION_CURRENT` moves it with the runtime.
// constant.
//
// The floor is `mintedSpecVersion()`, not `SPEC_VERSION_CURRENT`: what a
// World is told to stamp is that function (see the building-a-world
// guide), and the two differ whenever a version is readable before it is
// mintable. Pinning the constant here would fail every World the moment a
// spec bump raises the ceiling ahead of the default, which is the normal
// mid-bump state rather than a conformance defect.
const run = await server.getRun(result.runId);
expect(run.specVersion).toBeGreaterThanOrEqual(SPEC_VERSION_CURRENT);
expect(run.specVersion).toBeGreaterThanOrEqual(mintedSpecVersion());
expect(run.specVersion).toBeLessThanOrEqual(SPEC_VERSION_MAX_SUPPORTED);

const events = await server.getEvents(result.runId);
Expand Down
31 changes: 19 additions & 12 deletions packages/world/src/spec-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,32 @@ describe('spec version constants', () => {
});

describe('mintedSpecVersion', () => {
it('stamps the sealed-log version by default', () => {
expect(mintedSpecVersion({})).toBe(SPEC_VERSION_CURRENT);
expect(mintedSpecVersion({})).toBe(SPEC_VERSION_SUPPORTS_SEALED_LOG);
it('stamps the slot-identity version by default', () => {
// Stamping trails reading. Until every reader in the fleet accepts
// spec 7 — the Python runtime still caps at 6 — and pre-assigned
// positions stop stranding runs, a new run gets the version the whole
// fleet can already serve.
expect(mintedSpecVersion({})).toBe(SPEC_VERSION_SUPPORTS_SLOT_IDENTITY);
expect(mintedSpecVersion({})).not.toBe(SPEC_VERSION_SUPPORTS_SEALED_LOG);
});

it('falls back to slot identity when switched off', () => {
for (const off of ['0', 'false']) {
expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: off })).toBe(
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY
it('stamps the sealed-log version when opted in', () => {
for (const on of ['1', 'true']) {
expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: on })).toBe(
SPEC_VERSION_CURRENT
);
expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: on })).toBe(
SPEC_VERSION_SUPPORTS_SEALED_LOG
);
}
});

it('stays on by default for an unset or malformed value', () => {
// A flag is an escape hatch, not a hard requirement: a typo must not
// silently move a deployment onto the older identity scheme.
for (const raw of ['', '1', 'true', 'yes-please']) {
it('stays off for an unset, empty, or malformed value', () => {
// Opting in is deliberate: a typo must not silently move a deployment
// onto a scheme its readers may not accept.
for (const raw of ['', '0', 'false', 'yes-please']) {
expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: raw })).toBe(
SPEC_VERSION_CURRENT
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY
);
}
});
Expand Down
47 changes: 31 additions & 16 deletions packages/world/src/spec-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,36 +102,51 @@ export const SPEC_VERSION_CURRENT =
SPEC_VERSION_SUPPORTS_SEALED_LOG as SpecVersion;

/**
* Environment variable that opts new runs OUT of the sealed log.
* Environment variable that opts new runs IN to the sealed log.
*
* Read per `createWorld()` call rather than at module load, so a test or a
* single process can create worlds in both modes.
*/
export const SEALED_LOG_ENV_VAR = 'WORKFLOW_SEALED_LOG';

/**
* The spec version a World should stamp on the runs it creates: the sealed log
* unless {@link SEALED_LOG_ENV_VAR} switches it off, in which case the
* slot-identity version it supersedes.
*
* Same shape, and the same reasoning, as the flag slot identity itself shipped
* behind before going unconditional: default on, with one env var to put a
* deployment back on the previous scheme without a release.
*
* The fallback is a real fallback, not a formality. Turning this off has to
* leave a World the runtime still admits, which is why
* The spec version a World should stamp on the runs it creates: the
* slot-identity version unless {@link SEALED_LOG_ENV_VAR} opts in to the
* sealed log that supersedes it.
*
* Reading and stamping are separate stages of a spec bump, and this is the
* first of them: every build already reads a sealed log and skips `noop` (see
* {@link SPEC_VERSION_MAX_SUPPORTED}), while stamping stays behind the flag
* until the version is safe to mint everywhere. Two things have to be true
* before that default flips, and neither is yet:
*
* - **Every reader in the fleet has to accept spec 7.** A runtime that pins
* its own accepted range separately does not move with this constant. The
* Python runtime validates `specVersion <= 6` and rejects a spec-7
* `run_started` outright, so stamping 7 by default makes every run it serves
* unrunnable.
* - **Pre-assigned positions have to be free of the stall they currently
* cause.** Assigning a position before the write commits is what lets a
* claim be abandoned, and abandoned claims are observably stranding runs:
* spec-7 runs stall between a step outcome and the resume that should follow
* it, and only the queue's own redelivery (order of ten minutes later) moves
* them on. Measured against spec-6 runs on the same backend in the same
* window, spec 7 stalls roughly 30x as often.
*
* The fallback is a real fallback, not a formality. Stamping the lower version
* has to leave a World the runtime still admits, which is why
* `assertWorldSupportsRuntimeProtocol` floors at the slot-identity version
* rather than at {@link SPEC_VERSION_CURRENT} because a kill switch that made
* the runtime reject its own World would be no kill switch at all.
* rather than at {@link SPEC_VERSION_CURRENT} because a default that made the
* runtime reject its own World would be no default at all.
*
* Every World reads runs up to {@link SPEC_VERSION_MAX_SUPPORTED} whatever
* this returns, so switching it off here does not make runs another process
* created unreadable.
* this returns, so leaving it off here does not make runs another process
* created unreadable — including the spec-7 runs created while it was on.
*/
export function mintedSpecVersion(
env: Record<string, string | undefined> = process.env
): SpecVersion {
return envFlag(SEALED_LOG_ENV_VAR, true, env)
return envFlag(SEALED_LOG_ENV_VAR, false, env)
? SPEC_VERSION_CURRENT
: SPEC_VERSION_SUPPORTS_SLOT_IDENTITY;
}
Expand Down
Loading