Skip to content

world-postgres: enforce per-(run, correlation) uniqueness for entity-creating events#1878

Open
TooTallNate wants to merge 1 commit intomainfrom
world-postgres-event-uniqueness
Open

world-postgres: enforce per-(run, correlation) uniqueness for entity-creating events#1878
TooTallNate wants to merge 1 commit intomainfrom
world-postgres-event-uniqueness

Conversation

@TooTallNate
Copy link
Copy Markdown
Member

Summary

Adds a unique partial index on workflow_events(run_id, correlation_id, type) filtered to step_created / hook_created / wait_created, and translates the resulting unique-violation (pg code 23505, surfaced via DrizzleQueryError.cause) into EntityConflictError.

Background

Concurrent invocations producing identical correlationIds previously both succeeded at the events-table level, leaving duplicate rows in the log. The steps table already deduped via onConflictDoNothing, but the corresponding event row still inserted, so the storage was internally inconsistent (one step, two step_created events). The runtime's existing dedup catch path (if (EntityConflictError.is(err)) continue in runtime/snapshot-entrypoint.ts) was the intended consumer of this signal but never received it from world-postgres.

This is the postgres counterpart of PR #1877 (world-local).

Fix

  • New unique partial index workflow_events_entity_creation_unique on (run_id, correlation_id, type) filtered to entity-creating events (migration 0010_add_events_entity_creation_unique_index.sql).
  • events.create() wraps the INSERT in try/catch, detects pg 23505 (read off DrizzleQueryError.cause.code), and re-throws as EntityConflictError for the relevant event types.
  • 3 regression tests covering concurrent + sequential step_created and wait_created duplicate scenarios.

Verification

pnpm -F @workflow/world-postgres typecheck   # clean
pnpm -F @workflow/world-postgres build       # clean
pnpm -F @workflow/world-postgres test        # 106 passed (was 103 before the 3 regression tests)

Extracted from PR #1300 (snapshot-runtime). The snapshot runtime produces deterministic correlationIds across concurrent VM invocations of the same resumption by design — that path made the dedup gap reliably reproducible — but the fix is also valuable on its own for replay-runtime concurrent scenarios.

Copilot AI review requested due to automatic review settings April 30, 2026 08:24
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: 5ce19f0

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
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Apr 30, 2026 8:35am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Apr 30, 2026 8:35am
example-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-astro-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-express-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-fastify-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-hono-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-nitro-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-nuxt-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-sveltekit-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workbench-vite-workflow Ready Ready Preview, Comment Apr 30, 2026 8:35am
workflow-docs Ready Ready Preview, Comment, Open in v0 Apr 30, 2026 8:35am
workflow-swc-playground Ready Ready Preview, Comment Apr 30, 2026 8:35am
workflow-web Ready Ready Preview, Comment Apr 30, 2026 8:35am

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.038s (-13.8% 🟢) 1.006s (~) 0.968s 10 1.00x
🐘 Postgres Express 0.048s (-17.8% 🟢) 1.010s (~) 0.962s 10 1.25x
💻 Local Nitro 0.050s (+15.3% 🔺) 1.020s (+1.4%) 0.970s 10 1.30x
🐘 Postgres Nitro 0.062s (-34.7% 🟢) 1.011s (-3.1%) 0.949s 10 1.63x
workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.097s (-2.5%) 2.006s (~) 0.909s 10 1.00x
💻 Local Nitro 1.099s (-2.8%) 2.005s (~) 0.906s 10 1.00x
🐘 Postgres Express 1.113s (-2.9%) 2.010s (~) 0.897s 10 1.02x
🐘 Postgres Nitro 1.142s (~) 2.009s (~) 0.867s 10 1.04x
workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 10.630s (-2.9%) 11.021s (~) 0.391s 3 1.00x
💻 Local Express 10.681s (-2.2%) 11.023s (~) 0.342s 3 1.00x
🐘 Postgres Express 10.750s (-1.9%) 11.019s (~) 0.269s 3 1.01x
🐘 Postgres Nitro 10.945s (+0.7%) 11.025s (~) 0.080s 3 1.03x
workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 14.001s (-4.0%) 14.422s (-4.0%) 0.421s 5 1.00x
💻 Local Express 14.226s (-5.0%) 15.030s (~) 0.804s 4 1.02x
💻 Local Nitro 14.226s (-5.6% 🟢) 15.028s (-6.3% 🟢) 0.802s 4 1.02x
🐘 Postgres Nitro 14.566s (~) 15.024s (~) 0.457s 4 1.04x
workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 13.003s (-7.2% 🟢) 13.447s (-7.9% 🟢) 0.444s 7 1.00x
🐘 Postgres Nitro 14.154s (+1.3%) 14.879s (+4.0%) 0.725s 7 1.09x
💻 Local Express 14.795s (-10.9% 🟢) 15.028s (-11.8% 🟢) 0.233s 6 1.14x
💻 Local Nitro 14.861s (-11.5% 🟢) 15.027s (-11.8% 🟢) 0.166s 6 1.14x
Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.207s (-4.2%) 2.009s (~) 0.802s 15 1.00x
🐘 Postgres Nitro 1.273s (~) 2.010s (~) 0.738s 15 1.05x
💻 Local Express 1.450s (-2.6%) 2.005s (~) 0.555s 15 1.20x
💻 Local Nitro 1.479s (-9.4% 🟢) 2.005s (-3.3%) 0.526s 15 1.23x
Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.293s (-2.9%) 3.010s (~) 0.717s 10 1.00x
🐘 Postgres Nitro 2.353s (~) 3.010s (~) 0.656s 10 1.03x
💻 Local Nitro 2.630s (-16.4% 🟢) 3.007s (-22.6% 🟢) 0.378s 10 1.15x
💻 Local Express 2.761s (-6.5% 🟢) 3.108s (-10.0% 🟢) 0.347s 10 1.20x
Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 3.361s (-3.6%) 4.009s (~) 0.648s 8 1.00x
🐘 Postgres Nitro 3.503s (+0.7%) 4.014s (~) 0.511s 8 1.04x
💻 Local Nitro 6.754s (-19.1% 🟢) 7.018s (-22.2% 🟢) 0.263s 5 2.01x
💻 Local Express 6.879s (-17.5% 🟢) 7.215s (-20.1% 🟢) 0.336s 5 2.05x
Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.198s (-4.7%) 2.007s (~) 0.810s 15 1.00x
🐘 Postgres Nitro 1.261s (~) 2.008s (~) 0.747s 15 1.05x
💻 Local Express 1.478s (-21.9% 🟢) 2.006s (-15.1% 🟢) 0.527s 15 1.23x
💻 Local Nitro 1.482s (-20.6% 🟢) 2.006s (-14.3% 🟢) 0.524s 15 1.24x
Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.277s (-2.8%) 3.009s (~) 0.732s 10 1.00x
🐘 Postgres Nitro 2.351s (~) 3.010s (~) 0.659s 10 1.03x
💻 Local Nitro 2.666s (-13.0% 🟢) 3.009s (-22.6% 🟢) 0.342s 10 1.17x
💻 Local Express 2.713s (-13.4% 🟢) 3.007s (-20.1% 🟢) 0.294s 10 1.19x
Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 3.385s (-3.3%) 4.010s (~) 0.626s 8 1.00x
🐘 Postgres Nitro 3.473s (~) 4.009s (~) 0.537s 8 1.03x
💻 Local Nitro 7.420s (-18.9% 🟢) 8.016s (-20.0% 🟢) 0.596s 4 2.19x
💻 Local Express 7.665s (-12.9% 🟢) 8.021s (-13.5% 🟢) 0.355s 4 2.26x
workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.593s (-29.3% 🟢) 1.006s (-1.7%) 0.413s 60 1.00x
💻 Local Nitro 0.703s (-28.3% 🟢) 1.004s (-8.2% 🟢) 0.301s 60 1.18x
💻 Local Express 0.713s (-27.5% 🟢) 1.005s (-6.6% 🟢) 0.292s 60 1.20x
🐘 Postgres Nitro 0.810s (-1.3%) 1.005s (~) 0.195s 60 1.36x
workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.441s (-27.1% 🟢) 2.007s (-11.1% 🟢) 0.566s 45 1.00x
🐘 Postgres Nitro 1.960s (+1.7%) 2.202s (+4.9%) 0.243s 41 1.36x
💻 Local Nitro 2.225s (-26.7% 🟢) 3.007s (-20.0% 🟢) 0.782s 30 1.54x
💻 Local Express 2.468s (-18.2% 🟢) 3.181s (-11.3% 🟢) 0.712s 29 1.71x
workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.970s (-25.6% 🟢) 3.306s (-24.3% 🟢) 0.336s 37 1.00x
🐘 Postgres Nitro 3.978s (-3.1%) 4.404s (-4.3%) 0.426s 28 1.34x
💻 Local Nitro 7.302s (-21.5% 🟢) 8.015s (-20.0% 🟢) 0.713s 15 2.46x
💻 Local Express 7.442s (-19.2% 🟢) 8.015s (-20.0% 🟢) 0.573s 15 2.51x
workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.222s (-21.3% 🟢) 1.006s (~) 0.784s 60 1.00x
🐘 Postgres Nitro 0.298s (+5.3% 🔺) 1.007s (~) 0.708s 60 1.34x
💻 Local Nitro 0.547s (-9.5% 🟢) 1.004s (-1.7%) 0.457s 60 2.46x
💻 Local Express 0.624s (+11.4% 🔺) 1.021s (+1.7%) 0.397s 59 2.81x
workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.357s (-29.9% 🟢) 1.006s (~) 0.648s 90 1.00x
🐘 Postgres Nitro 0.493s (-0.8%) 1.006s (~) 0.513s 90 1.38x
💻 Local Nitro 2.328s (-8.3% 🟢) 3.007s (~) 0.680s 30 6.51x
💻 Local Express 2.444s (-2.8%) 3.008s (~) 0.564s 30 6.84x
workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.572s (-30.1% 🟢) 1.006s (-1.1%) 0.434s 120 1.00x
🐘 Postgres Nitro 0.788s (~) 1.016s (+0.9%) 0.228s 119 1.38x
💻 Local Nitro 10.097s (-9.8% 🟢) 10.690s (-8.4% 🟢) 0.593s 12 17.65x
💻 Local Express 10.622s (-5.1% 🟢) 11.023s (-7.7% 🟢) 0.401s 11 18.57x
Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 0.137s (-35.9% 🟢) 1.004s (~) 0.010s (-20.0% 🟢) 1.016s (~) 0.879s 10 1.00x
💻 Local Express 0.145s (-27.0% 🟢) 1.004s (~) 0.009s (-24.0% 🟢) 1.015s (~) 0.870s 10 1.06x
🐘 Postgres Express 0.156s (-24.0% 🟢) 0.999s (~) 0.001s (-18.8% 🟢) 1.009s (~) 0.853s 10 1.14x
🐘 Postgres Nitro 0.218s (+6.3% 🔺) 0.998s (~) 0.002s (~) 1.010s (~) 0.792s 10 1.59x
stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.505s (-19.9% 🟢) 1.005s (~) 0.004s (-8.0% 🟢) 1.021s (~) 0.516s 59 1.00x
💻 Local Nitro 0.584s (-30.3% 🟢) 1.011s (~) 0.010s (+6.5% 🔺) 1.023s (-8.3% 🟢) 0.439s 59 1.16x
🐘 Postgres Nitro 0.620s (-0.6%) 1.006s (~) 0.004s (-3.7%) 1.022s (~) 0.402s 59 1.23x
💻 Local Express 0.695s (-8.3% 🟢) 1.011s (-1.7%) 0.010s (+1.3%) 1.115s (+7.2% 🔺) 0.421s 54 1.38x
10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.916s (-4.7%) 1.064s (-16.7% 🟢) 0.000s (-17.9% 🟢) 1.078s (-17.4% 🟢) 0.162s 56 1.00x
🐘 Postgres Nitro 0.976s (+0.7%) 1.270s (+1.8%) 0.000s (+2.1%) 1.285s (+2.1%) 0.309s 47 1.06x
💻 Local Nitro 1.161s (-5.1% 🟢) 2.019s (~) 0.000s (+200.0% 🔺) 2.021s (~) 0.860s 30 1.27x
💻 Local Express 1.206s (-1.6%) 2.019s (~) 0.000s (-50.0% 🟢) 2.021s (~) 0.815s 30 1.32x
fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.754s (-1.0%) 2.143s (-1.6%) 0.000s (+Infinity% 🔺) 2.151s (-2.2%) 0.398s 28 1.00x
🐘 Postgres Nitro 1.829s (+2.1%) 2.258s (+5.5% 🔺) 0.000s (+3.7%) 2.270s (+4.4%) 0.441s 27 1.04x
💻 Local Nitro 3.518s (+3.8%) 4.031s (~) 0.000s (-62.5% 🟢) 4.033s (~) 0.516s 15 2.01x
💻 Local Express 3.936s (+13.5% 🔺) 4.104s (+1.7%) 0.000s (-55.4% 🟢) 4.464s (+10.6% 🔺) 0.528s 14 2.24x

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 15/21
🐘 Postgres Express 21/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 17/21
Nitro 🐘 Postgres 14/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 988 1 67 1056
✅ 💻 Local Development 1066 0 86 1152
✅ 📦 Local Production 1066 0 86 1152
❌ 🐘 Local Postgres 1062 4 86 1152
✅ 📋 Other 270 0 18 288
Total 4452 5 343 4800

❌ Failed Tests

▲ Vercel Production (1 failed)

nextjs-turbopack (1 failed):

  • outputStreamWorkflow no startIndex (reads all chunks)
🐘 Local Postgres (4 failed)

astro-stable (2 failed):

  • fibonacciWorkflow - recursive workflow composition via start() | wrun_01KQERQVBRFW0CY2MDTA3WQRA3
  • health check (queue-based) - workflow and step endpoints respond to health check messages

sveltekit-stable (2 failed):

  • fibonacciWorkflow - recursive workflow composition via start() | wrun_01KQERQVBRFW0CY2MDTA3WQRA3
  • health check (queue-based) - workflow and step endpoints respond to health check messages

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 89 0 7
✅ example 89 0 7
✅ express 89 0 7
✅ fastify 89 0 7
✅ hono 89 0 7
❌ nextjs-turbopack 93 1 2
✅ nextjs-webpack 94 0 2
✅ nitro 89 0 7
✅ nuxt 89 0 7
✅ sveltekit 89 0 7
✅ vite 89 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 90 0 6
✅ express-stable 90 0 6
✅ fastify-stable 90 0 6
✅ hono-stable 90 0 6
✅ nextjs-turbopack-canary 77 0 19
✅ nextjs-turbopack-stable 96 0 0
✅ nextjs-webpack-canary 77 0 19
✅ nextjs-webpack-stable 96 0 0
✅ nitro-stable 90 0 6
✅ nuxt-stable 90 0 6
✅ sveltekit-stable 90 0 6
✅ vite-stable 90 0 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 90 0 6
✅ express-stable 90 0 6
✅ fastify-stable 90 0 6
✅ hono-stable 90 0 6
✅ nextjs-turbopack-canary 77 0 19
✅ nextjs-turbopack-stable 96 0 0
✅ nextjs-webpack-canary 77 0 19
✅ nextjs-webpack-stable 96 0 0
✅ nitro-stable 90 0 6
✅ nuxt-stable 90 0 6
✅ sveltekit-stable 90 0 6
✅ vite-stable 90 0 6
❌ 🐘 Local Postgres
App Passed Failed Skipped
❌ astro-stable 88 2 6
✅ express-stable 90 0 6
✅ fastify-stable 90 0 6
✅ hono-stable 90 0 6
✅ nextjs-turbopack-canary 77 0 19
✅ nextjs-turbopack-stable 96 0 0
✅ nextjs-webpack-canary 77 0 19
✅ nextjs-webpack-stable 96 0 0
✅ nitro-stable 90 0 6
✅ nuxt-stable 90 0 6
❌ sveltekit-stable 88 2 6
✅ vite-stable 90 0 6
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 90 0 6
✅ e2e-local-postgres-nest-stable 90 0 6
✅ e2e-local-prod-nest-stable 90 0 6

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: cancelled

Check the workflow run for details.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a Postgres-level deduplication guard for entity-creating events so concurrent (or repeated) creates with the same (runId, correlationId, type) surface as EntityConflictError, aligning world-postgres behavior with the runtime’s existing dedup contract.

Changes:

  • Add a partial unique index on workflow_events(run_id, correlation_id, type) for step_created / hook_created / wait_created.
  • Translate Postgres unique-violation (23505) during events.create() into EntityConflictError for entity-creating event types.
  • Add regression tests for duplicate step_created and wait_created scenarios.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/world-postgres/src/storage.ts Catch and translate unique-violation errors into EntityConflictError for entity-creating events.
packages/world-postgres/src/drizzle/schema.ts Add Drizzle schema definition for the partial unique index.
packages/world-postgres/src/drizzle/migrations/0010_add_events_entity_creation_unique_index.sql Create the partial unique index in SQL migration.
packages/world-postgres/src/drizzle/migrations/meta/_journal.json Register the new migration in the Drizzle journal.
packages/world-postgres/test/storage.test.ts Add tests asserting duplicate entity-creation attempts surface as EntityConflictError and don’t duplicate event rows.
.changeset/fix-world-postgres-events-uniqueness.md Publish a patch changeset describing the new uniqueness + error translation behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

-- causing duplicate step/hook/wait events in the log. The unique
-- violation is caught in events.create and surfaced as
-- EntityConflictError, which the runtime already handles as a dedup
-- signal.
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

Creating a UNIQUE index will fail if the table already contains duplicate (run_id, correlation_id, type) rows for the filtered event types. Since the previous storage behavior could emit duplicates, this migration is likely to fail on existing installations unless it first deduplicates those rows (e.g., delete all but the earliest row per (run_id, correlation_id, type) for the affected types) before creating the index.

Suggested change
-- signal.
-- signal.
--
-- Existing installations may already contain duplicates for these event
-- types, so deduplicate them before creating the unique partial index.
WITH "ranked_workflow_events" AS (
SELECT
ctid,
ROW_NUMBER() OVER (
PARTITION BY "run_id", "correlation_id", "type"
ORDER BY ctid
) AS "row_num"
FROM "workflow"."workflow_events"
WHERE "type" IN ('step_created', 'hook_created', 'wait_created')
)
DELETE FROM "workflow"."workflow_events"
WHERE ctid IN (
SELECT ctid
FROM "ranked_workflow_events"
WHERE "row_num" > 1
);

Copilot uses AI. Check for mistakes.
Comment on lines +1265 to +1277
const isEntityCreatingEvent =
data.eventType === 'step_created' ||
data.eventType === 'hook_created' ||
data.eventType === 'wait_created';
const pgCode = ((err as { code?: string }).code ??
(err as { cause?: { code?: string } }).cause?.code) as
| string
| undefined;
if (isEntityCreatingEvent && pgCode === '23505') {
throw new EntityConflictError(
`${data.eventType} for correlationId "${data.correlationId}" already exists in run "${effectiveRunId}"`
);
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The 23505 handling is currently based only on the Postgres error code and event type. Consider additionally checking the violated constraint/index name (e.g. the pg error's constraint field) so other unique violations on these event types (like the events primary key) don’t get misclassified as a correlationId conflict.

Copilot uses AI. Check for mistakes.
Comment on lines +1188 to +1191
// Sequential duplicate wait_created — the existing TOCTOU read
// catches this case, but the unique index now provides a stronger
// guarantee that survives concurrent writers.
await events.create(testRunId, {
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

This comment mentions an “existing TOCTOU read”, but the current wait_created implementation uses an INSERT ... onConflictDoNothing() + check, not a read-then-write pattern. Updating the comment (or asserting the event log has exactly one wait_created event like the step test) would make the test intent clearer and avoid misleading future readers.

Copilot uses AI. Check for mistakes.
…in world-postgres

Adds a unique partial index on workflow_events(run_id, correlation_id, type)
filtered to step_created/hook_created/wait_created, and translates the
resulting unique-violation (pg code 23505, surfaced via DrizzleQueryError.cause)
into EntityConflictError. The steps table already deduped via
onConflictDoNothing, but the event row still inserted, leaving duplicate
events in the log. Now both rows are kept consistent and the runtime's
existing dedup catch path handles concurrent writers cleanly.
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.

3 participants