Skip to content

fix(world-postgres): make step creation atomic - #3575

Merged
VaguelySerious merged 1 commit into
vercel:mainfrom
shin4141:codex/v42-atomic-step-created-event
Aug 22, 2026
Merged

fix(world-postgres): make step creation atomic#3575
VaguelySerious merged 1 commit into
vercel:mainfrom
shin4141:codex/v42-atomic-step-created-event

Conversation

@shin4141

Copy link
Copy Markdown
Contributor

What

  • insert a Postgres step entity and its matching step_created event in one transaction
  • keep event-slot allocation and entity-conflict handling inside the existing read-committed semantics
  • add a deterministic regression that forces the event insert to fail and verifies the step claim rolls back
  • add the required patch changeset for @workflow/world-postgres

Why

step_created previously committed the workflow_steps row before attempting the workflow_events insert. If the process or database failed between those writes, replay had no creation event while the exactly-once entity claim remained. Subsequent replay could only rediscover the same conflict and leave the run wedged.

This fixes the step_created partial-write window reported in #3081 without changing the public event contract or expanding the repair to other event types.

Root cause and impact

The entity mutation and its replay evidence used separate autocommit statements. The repair moves only this pair behind a single transaction. A failed event write now rolls the entity claim back; a successful call still returns the same step/event result and preserves dense event slots.

Checks

  • red-before: forced event-insert failure left { stepId: 'step_partial_write' } in workflow_steps
  • green-after: targeted rollback regression passed
  • packages/world-postgres/test/storage.test.ts: 141 passed against an isolated PostgreSQL 17.10 database with repository migrations
  • @workflow/world-postgres build: passed
  • @workflow/world-postgres typecheck: passed
  • queue tests: 12 passed
  • re-enqueue tests: 12 passed
  • util tests: 2 passed
  • Biome check: no errors (repository-existing warnings remain)
  • changeset status: @workflow/world-postgres patch detected

The repository-standard Testcontainers spec suite is left to CI because Docker is not available in the local execution environment.

@shin4141
shin4141 requested a review from a team as a code owner August 15, 2026 03:43
@changeset-bot

changeset-bot Bot commented Aug 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3fb90ec

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@workflow/world-postgres Patch

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

@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

@shin4141 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@shin4141
shin4141 force-pushed the codex/v42-atomic-step-created-event branch from b9727fe to 82be579 Compare August 15, 2026 03:44

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: blocking issues found

Comment thread packages/world-postgres/src/storage.ts Outdated
.onConflictDoNothing()
.returning();
if (!stepValue) {
throw new EntityConflictError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Blocking

Keying the conflict on the step row instead of on the step_created event deletes the only recovery path that exists today for an already-orphaned step row, and replaces it with a corrupted event log.

On the base commit, a zero-row onConflictDoNothing() fell through to the event insert, so an orphan (row committed, event lost) was completed by the next step_created. This branch throws before the event insert, so the orphan can never be completed.

Measured against an isolated Postgres 15: seed one orphan row for stepId = step_orphan, then send the matching step_created, then the consumer's plain step_started.

step_created resulting event log for that step
base (b0adb50) resolves [step_created, step_started]
this PR EntityConflictError: Step "step_orphan" already created [step_started]

Both runtime engines swallow EntityConflictError on step_created (quickjs-entrypoint.ts: "Concurrent invocation wrote it first — the message is already out"; suspension-handler.ts: "Step already exists, continuing") and dispatch the step regardless. The consumer's non-lazy step_started then succeeds, because getStepForValidation finds the orphan row. So the run does not error: it produces a step whose log has step_started and no step_created, permanently. The replay consumer re-derives that step as uncreated on every replay while the step also executes.

That is strictly worse than the state #3081 describes. The population the issue is about is the pre-existing orphans (it reports 15 accumulated over 11 days on one database). After this change none of them can be drained, and every retry against one corrupts its run's log.

The issue's suggested fix (2) is the missing half, and the hook path a few hundred lines up is already the precedent: on a zero-row claim, look up whether step_created exists for (runId, correlationId). Present → EntityConflictError, as here. Absent → orphaned partial write, so skip the row insert and complete the event insert in the same transaction. That closes the new window and drains the existing orphans, which is what makes this a fix for the reported issue rather than a change of failure mode.

Two smaller points in the same area:

  • The error message loses the run id. Base produced step_created for correlationId "X" already exists in run "Y" via the 23505 translation below; this produces Step "X" already created. Duplicate correlationIds are per-run, so the run id is the useful half for an operator reading self-hosted logs.
  • wait_created still has exactly the shape this branch introduces: non-transactional insert, EntityConflictError keyed on the entity row, no event-existence check. step_created entity row and event are written non-transactionally — a crash between them wedges the run permanently #3081 names it and attr_set as sharing the pattern. Not this PR's scope, but it means the codebase now has one path that is atomic-and-unrecoverable and one that is neither.

let inserted: Awaited<ReturnType<typeof insertEventRow>>;
if (data.eventType === 'step_created') {
const eventData = data.eventData;
const created = await drizzle.transaction(async (tx) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

Wrapping the fan-out write in an explicit transaction has a width-dependent cost. N concurrent step_created calls on one run, isolated Postgres 15, pg pool max: 30, 3 independent runs of 15 rounds each per side, first round of each run discarded, n=42 per cell:

width base median this PR median factor
1 2.0 ms 1.8 ms 0.90x
5 3.8 ms 4.7 ms 1.24x
10 6.3 ms 9.6 ms 1.52x
20 16.1 ms (10.9–35) 36.5 ms (19.7–90.9) 2.27x

Width 1 is unchanged, so this is not the BEGIN/COMMIT round trips. I read it as contention amplification, though I have not instrumented it to prove the mechanism: INSERT … ON CONFLICT DO NOTHING blocks on a conflicting uncommitted row, so the window in which a concurrent slot attempt stalls grows from a single autocommit statement to event insert → COMMIT. Each stalled writer that returns zero rows re-enters insertEventRow's retry loop (SLOT_INSERT_BASE_DELAY_MS, doubling past SLOT_INSERT_IMMEDIATE_ATTEMPTS), so the chain lengthens faster than linearly in width.

The existing suite cannot surface this: the harness pool is max: 1, so every "concurrent" case in storage.test.ts serialises at the driver before it reaches Postgres.

Worth deciding explicitly rather than by accident, since fan-out is the hottest events.create path in this world. If the cost is unwelcome, a single statement is atomic without an explicit transaction and keeps the blocking window at one statement:

WITH s AS (
  INSERT INTO workflow.workflow_steps (...) VALUES (...)
  ON CONFLICT DO NOTHING RETURNING step_id
)
INSERT INTO workflow.workflow_events (...) SELECT ... FROM s

The trade-off is that the slot-retry loop then re-runs the step insert too, so distinguishing "row conflict" from "slot retry" needs a follow-up read.

stepName: 'test-step',
input: new Uint8Array(),
})
).rejects.toThrow();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

rejects.toThrow() accepts any error. It happens to be the right one today, but it would also pass if the code threw before reaching the event insert at all, which is the failure mode worth guarding here. Asserting the forced failure specifically (rejects.toThrow(/forced step_created event insert failure/)) plus "no step_created event landed" alongside the existing row assertion would make the test prove atomicity rather than just absence.

@VaguelySerious

VaguelySerious commented Aug 21, 2026

Copy link
Copy Markdown
Member

@shin4141 See review above. Also, all commits on a PR must have a signature in order for us to merge it. Please squash+sign+force push after addressing comments

@shin4141
shin4141 force-pushed the codex/v42-atomic-step-created-event branch from 82be579 to 75fffb4 Compare August 21, 2026 23:57
@VaguelySerious

Copy link
Copy Markdown
Member

Commits must have verified signatures

Still not passing

Signed-off-by: Shin <128954611+shin4141@users.noreply.github.com>
@shin4141
shin4141 force-pushed the codex/v42-atomic-step-created-event branch from 75fffb4 to 3fb90ec Compare August 22, 2026 02:06
@shin4141

Copy link
Copy Markdown
Contributor Author

Thanks — the commit is now cryptographically signed and GitHub reports it as Verified. I preserved the tree unchanged and force-pushed the single signed commit. New head: 3fb90ec3f23b9518b0bf968bebb9d9efeb316496.

@VaguelySerious
VaguelySerious merged commit 71bc027 into vercel:main Aug 22, 2026
5 of 23 checks passed
github-actions Bot added a commit that referenced this pull request Aug 22, 2026
Signed-off-by: Shin <128954611+shin4141@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #3746. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants