Skip to content

world-postgres: create run row, slot marker, and run_created event atomically #3656

Closed

Description

@tbharthur

Package: @workflow/world-postgres@5.0.0-beta.34 (@workflow/world@5.0.0-beta.27), Node 22, PostgreSQL 17 (Cloud SQL).

@workflow/world-postgres@5.0.0-beta.34 can create a current slot-numbered run with a legacy ULID event because run creation is split across three autocommits.

allocateEventId() treats absence of a workflow_event_slots row as the signal that a run predates slot numbering and returns wevt_<ulid>. Both run creation paths currently make the run visible before inserting that marker:

INSERT workflow_runs                  -- commits
INSERT workflow_event_slots           -- commits later
INSERT workflow_events (run_created)  -- commits later

The affected paths are the resilient run_started creation path and the ordinary run_created path in packages/world-postgres/src/storage.ts.

A deterministic reproduction is:

  1. delay INSERT on workflow.workflow_event_slots with a test trigger;
  2. call world.events.create(runId, { eventType: "run_created", ... });
  3. as soon as the workflow_runs row is visible, concurrently call world.events.create(runId, { eventType: "run_started" });
  4. inspect workflow.workflow_events.

On beta.34 the persisted log contains slot 1 run_created plus a wevt_<ulid> run_started. The runtime later rejects that log because the event ID is not slot-numbered.

Please wrap the run row, slot marker, and first run_created event in one drizzle.transaction() at both creation sites. Preserve onConflictDoNothing() and the resilient path's re-read when a concurrent ordinary run_created wins. The ordinary path must also insert its run_created event inside the transaction and skip the shared tail insertion, otherwise the marker can commit before slot 1 and a concurrent writer can take the first slot.

The invariant should be: for a slot-era run, no transaction can observe the run row without also observing its slot marker and slot-1 run_created event.

We are carrying this as a local pnpm patch (both creation sites wrapped in one drizzle.transaction()) with a real-Postgres concurrency regression; happy to open a PR if that shape is acceptable.

Activity

  1. samtarifflens commented on Sep 6, 2026

    @samtarifflens

    Deterministic reproduction and a patch (world-postgres 5.0.0-beta.40)

    We hit this in production with two API tasks on one Postgres world. A run's
    second event was written by a worker that had not yet seen the run's
    workflow_event_slots marker, so allocateEventId took the legacy branch and
    minted wevt_01M1VSBYJT…. Every later insert for that run computed its slot
    from the highest existing id, hit a ULID body where it expected a number, and
    refused the log ("Event id is not slot-numbered"). The run sat RUNNING for four
    minutes and was settled ERROR by our stall watchdog. The answer it was carrying
    was complete and correct.

    The fault is visibility, not concurrency, so it reproduces in one process.
    Widen the window with a trigger:

    CREATE FUNCTION delay_slot_marker() RETURNS trigger LANGUAGE plpgsql AS $$
    BEGIN PERFORM pg_sleep(1); RETURN NEW; END $$;
    CREATE TRIGGER delay_slot_marker BEFORE INSERT ON workflow.workflow_event_slots
      FOR EACH ROW EXECUTE FUNCTION delay_slot_marker();

    Start any workflow, and poll every 10 ms:

    SELECT r.id, (SELECT count(*) FROM workflow.workflow_event_slots s WHERE s.run_id = r.id) AS markers
    FROM workflow.workflow_runs r WHERE r.created_at > now() - interval '5 seconds';

    On beta.40 the run comes back with markers = 0 for the whole second: the
    run_created branch of createEventsStorage(...).create inserts the run row,
    then calls openEventSlots, then inserts the first event — three separate
    auto-committed statements. Any writer that reads the run in that window is
    told, correctly as far as it can see, that the run predates slots.

    Our patch, both changes in src/storage.ts:

    1. One transaction per creation path. The run_created branch and the
      resilient run_started branch each wrap the run-row insert, openEventSlots
      and the first event's insert in a single
      drizzle.transaction(..., SLOT_INSERT_TRANSACTION). onConflictDoNothing()
      and the re-read on a lost creation race are preserved; a conflict on the
      first slot now rolls the run row back instead of leaving a run with no
      run_created event. (The resilient path's comment already promised this
      transaction.)
    2. allocateEventId hardened. It reads the marker FOR SHARE, and when
      there is no marker it checks whether the run's log already holds an
      evnt_-prefixed event before deciding the run is legacy. The transaction is
      what closes the window; this survives a path we did not think of, and costs
      an extra query only on runs that really are legacy.

    With both applied, the polling loop never observes a run without its marker and
    the log stays slot-numbered end to end. That assertion is now a regression test
    for us; before the patch it fails on every run.

    Related: with two workers we also hit #3758 (a booting peer re-enqueueing runs
    a live peer is executing). We answered that by electing a single queue worker
    with a Postgres advisory lock rather than by patching, so only one process ever
    calls reenqueueActiveRuns.

  2. komly commented on Sep 7, 2026

    @komly
    Contributor

    Another data point, with a different trigger than the two above: a process restart, not a concurrent second writer.

    Setup: eve@0.44.4 (runtime spec 6, pre-slot ids dropped per #3519) + @workflow/world-postgres@5.0.0-beta.35, one world process per agent in a single container; deploys replace the container, so no live peer — #3758 does not apply here.

    What we found in the database: three runs whose workflow_runs row existed (created 14:02, 14:19, 14:34) but with no run_created event and no workflow_event_slots row at all. Their first events were written at 14:37:35 — the second the replacement container booted — by reenqueueActiveRuns: run_started, step_created, step_started, step_completed, all with wevt_<ulid> ids because allocateEventId found no marker. Every later replay fails with Event id is not slot-numbered, and Graphile retries for 49 attempts with growing backoff. The runs were unrecoverable; we failed them with permanently_fail_jobs.

    So the window is not only a race between two writers: if the process dies between the run-row insert and the marker insert, the run stays half-created forever, and startup recovery then poisons it.

    Reproduction with the pg_sleep(1) trigger from the comment above, polling every 10 ms for a run visible without its marker, on beta.35:

    • unpatched: 79 observations in the 1 s window
    • with both creation paths wrapped in one drizzle.transaction(…, SLOT_INSERT_TRANSACTION) and allocateEventId checking the log for an existing evnt_ event before choosing the legacy id: 0 observations; final state identical (evnt_…01 run_created, marker present)

    We carry that as a pnpm patch on beta.35 until this lands. Happy to share the diff if useful.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions