Skip to content

Commit eb8fdb9

Browse files
Default WORKFLOW_PRECONDITION_GUARD on (#2946)
1 parent 918a2c5 commit eb8fdb9

7 files changed

Lines changed: 81 additions & 10 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"workflow": minor
3+
"@workflow/core": minor
4+
"@workflow/world": patch
5+
"@workflow/errors": patch
6+
---
7+
8+
The `WORKFLOW_PRECONDITION_GUARD` event-creation guard is now on by default; opt out with `WORKFLOW_PRECONDITION_GUARD=0`.

docs/content/docs/v5/configuration/runtime-tuning.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
4040

4141
### `WORKFLOW_PRECONDITION_GUARD`
4242

43-
- Default: disabled
44-
- Set `1` to enable an optimistic-concurrency guard for event creation: replay-context event creations send a `stateUpdatedAt` snapshot timestamp, and a backend that supports the guard rejects a creation with 412 ([`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error)) when a newer out-of-band event (a received hook or a completed step) was recorded after that snapshot.
43+
- Default: enabled
44+
- An optimistic-concurrency guard for event creation: replay-context event creations send a `stateUpdatedAt` snapshot timestamp, and a backend that supports the guard rejects a creation with 412 ([`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error)) when a newer out-of-band event (a received hook or a completed step) was recorded after that snapshot.
4545
- On rejection the runtime reloads the event log and retries, falling back to a queue re-invocation with a fresh replay if it cannot catch up.
4646
- When enabled — and the World declares that it enforces the guard (`capabilities.preconditionGuard`; the Vercel World does) — the runtime also keeps the per-step event-log delta optimization (consuming the delta returned by a step's terminal write instead of issuing an extra `events.list` per step) active while the run has an open hook. Without an enforced guard, an open hook disables it.
4747
- While a hook is open on a guard-enforcing deployment, inline steps take the await-then-run path even when optimistic inline start is enabled: the step's `step_started` claim carries the snapshot and is awaited before the body runs, so a claim the backend rejects as stale never executes user code.
4848
- Backends that do not support the guard ignore the snapshot; they must not declare the capability, so guard-dependent optimizations stay off against them even when the flag is set.
49+
- Set `0` to disable.
4950

5051
## Inline execution
5152

packages/core/src/runtime.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2655,6 +2655,17 @@ export function workflowEntrypoint(
26552655
// Serialize the original thrown value so its full
26562656
// type identity and custom properties round-trip
26572657
// through the event log.
2658+
//
2659+
// Precondition-guard asymmetry: unlike `run_completed`,
2660+
// this terminal `run_failed` sends no `stateUpdatedAt`
2661+
// snapshot, so it is never 412-rejected even if a hook
2662+
// landed mid-replay and could have changed the path that
2663+
// threw. This is intentional and fail-open: a spurious
2664+
// failure is recoverable (the run can be re-run from the
2665+
// dashboard), whereas a spurious *completion* commits a
2666+
// wrong result. Guarding this write symmetrically would
2667+
// also need the loaded event log, which is scoped to the
2668+
// replay `try` above and not available in this catch.
26582669
try {
26592670
// Turbo: order the terminal write after the
26602671
// backgrounded run_started so the run exists.

packages/core/src/runtime/helpers.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,8 +510,8 @@ describe('withPreconditionRetry', () => {
510510
}
511511
});
512512

513-
it('passes no snapshot to op when the guard is not opted in', async () => {
514-
delete process.env.WORKFLOW_PRECONDITION_GUARD;
513+
it('passes no snapshot to op when the guard is explicitly disabled', async () => {
514+
process.env.WORKFLOW_PRECONDITION_GUARD = '0';
515515
const log: MutableEventLog = {
516516
events: [makeUlidEvent(1_700_000_000_000)],
517517
cursor: 'c0',
@@ -528,6 +528,24 @@ describe('withPreconditionRetry', () => {
528528
expect(eventsListMock).not.toHaveBeenCalled();
529529
});
530530

531+
it('sends a snapshot by default when the guard variable is unset (on by default)', async () => {
532+
delete process.env.WORKFLOW_PRECONDITION_GUARD;
533+
const time = 1_700_000_000_000;
534+
const log: MutableEventLog = {
535+
events: [makeUlidEvent(time)],
536+
cursor: 'c0',
537+
};
538+
const op = vi.fn(async (stateUpdatedAt?: number) => {
539+
expect(stateUpdatedAt).toBe(time);
540+
return 'ok';
541+
});
542+
543+
await expect(withPreconditionRetry('wrun_test', log, op)).resolves.toBe(
544+
'ok'
545+
);
546+
expect(op).toHaveBeenCalledTimes(1);
547+
});
548+
531549
it('passes the latest snapshot time to op and returns its result without reloading', async () => {
532550
const time = 1_700_000_000_000;
533551
const log: MutableEventLog = {

packages/core/src/runtime/helpers.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -589,13 +589,15 @@ export interface MutableEventLog {
589589
}
590590

591591
/**
592-
* Whether the optimistic-concurrency guard for event creation is enabled
593-
* (`WORKFLOW_PRECONDITION_GUARD=1`, set where the runtime executes). Off by
594-
* default: replay-context creates only send a `stateUpdatedAt` snapshot (and
595-
* can therefore be rejected with 412 by the backend) when it is enabled.
592+
* Whether the optimistic-concurrency guard for event creation is enabled.
593+
* **On by default** where the runtime executes: replay-context creates send a
594+
* `stateUpdatedAt` snapshot (and can be rejected with 412 by a supporting
595+
* backend) unless `WORKFLOW_PRECONDITION_GUARD` is set to `0`. Backends without
596+
* guard support ignore the snapshot, so enabling by default is
597+
* backward-compatible.
596598
*/
597599
export function isPreconditionGuardEnabled(): boolean {
598-
return process.env.WORKFLOW_PRECONDITION_GUARD === '1';
600+
return process.env.WORKFLOW_PRECONDITION_GUARD !== '0';
599601
}
600602

601603
/**
@@ -620,7 +622,17 @@ export function latestEventStateUpdatedAt(events: Event[]): number | undefined {
620622
const eventId = last.eventId;
621623
const underscore = eventId.lastIndexOf('_');
622624
const rawUlid = underscore === -1 ? eventId : eventId.slice(underscore + 1);
623-
return ulidToDate(rawUlid)?.getTime() ?? undefined;
625+
const time = ulidToDate(rawUlid)?.getTime();
626+
if (time === undefined) {
627+
// Fail open: a non-decodable id disarms the guard for this create (no
628+
// snapshot sent). Log so a fleet-wide silent disarm is diagnosable.
629+
runtimeLogger.debug(
630+
'Precondition guard: latest event id is not a decodable ULID; sending no snapshot',
631+
{ eventId }
632+
);
633+
return undefined;
634+
}
635+
return time;
624636
}
625637

626638
/**
@@ -678,6 +690,13 @@ export async function withPreconditionRetry<T>(
678690
new Set(log.events.map((e) => e.eventId)),
679691
loaded.events
680692
);
693+
// When several creates share one `log` (e.g. hook creations under
694+
// `Promise.all` in `handleSuspension`), concurrent 412s can reload
695+
// concurrently. The event merge above is safe — `appendUniqueEvents`
696+
// builds its dedup set synchronously right before appending — but this
697+
// cursor write is last-write-wins, so an interleaved older reload can
698+
// briefly regress the cursor. The only consequence is refetching a few
699+
// already-deduped events on a later load; correctness is unaffected.
681700
log.cursor = loaded.cursor ?? log.cursor;
682701
}
683702
}

packages/errors/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,10 @@ export class ThrottleError extends WorkflowWorldError {
769769
* The workflow runtime handles this automatically: it reloads the event log
770770
* and retries, ultimately re-enqueueing the run if it cannot catch up. Users
771771
* interacting with world storage backends directly may encounter it.
772+
*
773+
* @property retryAfter - Delay in seconds before retrying. Accepted for
774+
* forward-compatibility; the runtime currently reloads and retries
775+
* immediately and does not read this field.
772776
*/
773777
export class PreconditionFailedError extends WorkflowWorldError {
774778
constructor(message: string, options?: { retryAfter?: number }) {

packages/world/src/events.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,16 @@ export interface CreateEventParams {
698698
* when a newer out-of-band event was recorded after this snapshot, enabling
699699
* an optimistic-concurrency guard. Omitted by callers without a loaded event
700700
* log.
701+
*
702+
* Backend contract (for World implementers who want to support the guard):
703+
* maintain a per-run marker holding the ULID time of the most recent
704+
* *externally-originated* event — a `hook_received` or `step_completed`
705+
* created **without** a `stateUpdatedAt` (replay-origin events carry one and
706+
* must not advance the marker). On a create that carries `stateUpdatedAt`,
707+
* reject with 412 when `stateUpdatedAt < marker` (strictly older); an equal
708+
* timestamp must pass (anti-livelock, so an up-to-date client is never
709+
* rejected). A backend that ignores this field simply disables the guard —
710+
* the client falls open and behaves as before.
701711
*/
702712
stateUpdatedAt?: number;
703713
/**

0 commit comments

Comments
 (0)