Postgres World permits concurrent ownership of the same hook token
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 365
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 169
Description
Summary
Concurrent claims for one hook token can all succeed in Postgres World, violating token uniqueness. This issue records a reproducible bug report for the existing proposed fix in #1970.
Verified results
Fresh independent reproduction on @workflow/world-postgres 5.0.0-beta.40 and 5.0.0-beta.43, using twelve separate Node processes and a fresh local PostgreSQL database for each package comparison:
| Package / mode | hook_created | hook_conflict | Returned hooks | Persisted rows | Distinct owners |
|---|---|---|---|---|---|
| Published beta.40, concurrent | 12 | 0 | 12 | 12 | 12 |
| Published beta.43, concurrent | 12 | 0 | 12 | 12 | 12 |
| ChatJS patched beta.40, concurrent | 12 | 0 | 12 | 12 | 12 |
| ChatJS patched beta.40, sequential control | 1 | 11 | 1 | 1 | 1 |
Environment: macOS 26.3 arm64, Node v24.20.0, Bun 1.3.11, PostgreSQL 17.11 on loopback. The npm beta tag was beta.43 on 2026-09-15. The separate latest tag was 4.3.6; that release line was not tested.
Reproduction
The inline race.mjs and race-worker.mjs below use the public createWorld API. Install the pinned dependencies, set WORKFLOW_POSTGRES_URL to a new disposable local database whose name begins with b2_repro_, and initialize that database with the package's setup command. Then run:
node race.mjs
node race.mjs --sequential
Each child creates its own workflow run and signals readiness. The parent releases all twelve to claim the same synthetic token. The fixture counts both public results and database ownership; it does not treat the row count alone as success. The sequential mode uses a new token and establishes that conflict reporting works when claims do not race. Full setup commands and the local-only guard are included below.
Expected vs. actual
Expected: one hook_created, eleven hook_conflict, and one persisted owner. Actual under concurrent claims: twelve successful hook creation events, twelve returned hooks, and twelve persisted owners. The assertion fails with 12 !== 1.
{
"package": "@workflow/world-postgres@5.0.0-beta.43",
"processes": 12,
"counts": { "hook_created": 12 },
"returnedHooks": 12,
"hookRows": 12,
"distinctOwners": 12
}
Our maintained beta.40 patch concerns queue cancellation and stream reads; it does not incorporate the uniqueness fix. No hosted or existing application database was used.
Fix and rollout questions
This supports the need for #1970. We have not freshly tested this PR's fixed implementation, its duplicate cleanup, or a mixed-version rollout.
- The current migration does implement duplicate cleanup: it retains the earliest
created_atrow, breaking ties withctid, and deletes the others. What is the intended outcome for workflows and event histories belonging to discarded live owners? - Our historical private old-writer/new-index control returned twelve success events while persisting one row. That is historical evidence, not a rerun in this report. Does rollout require quiescing writers or a staged compatible release? Can the supported deployment order be documented and tested?
- Could the regression verify public event outcomes and persisted distinct owners across independent processes, including retries?
Token uniqueness alone should not be presented as a guarantee of atomic or exactly-once workflow creation.
Runnable fixture
Save the three files below in a new directory, then follow these steps.
Requires Node 24, Bun, and a local PostgreSQL server. Uses synthetic workflow runs only. Never point setup at an existing application database.
bun install
# Create a fresh disposable database on your local PostgreSQL server:
createdb -h 127.0.0.1 b2_repro_manual
# Adapt the local role/port as needed. Keep credentials out of committed files.
export WORKFLOW_POSTGRES_URL=postgresql://localhost/b2_repro_manual
node node_modules/@workflow/world-postgres/bin/setup.js
node race.mjs
node race.mjs --sequential
race.mjs rejects non-loopback URLs and database names without the b2_repro_ prefix. The package's setup command does not have that guard: verify the URL before running it. Use a fresh database for each version/schema comparison; repeated race runs within a case use distinct tokens.
The concurrent run is expected to fail on beta.40 and beta.43: twelve successful owners instead of one. The sequential control should pass with one creation and eleven conflicts. Exit 0 means the expected contract passed; assertion failure exits 1. Setup/worker errors are not proof of the bug. A timeout kills the fixture workers.
To compare beta.43, use bun add @workflow/world-postgres@5.0.0-beta.43, create a new dedicated local database, and repeat setup and both runs. No model calls or Neon access are involved. Drop only your newly created task databases after collecting evidence.
package.json
{
"name": "workflow-postgres-hook-token-race-repro",
"private": true,
"type": "module",
"scripts": {
"repro": "node race.mjs"
},
"dependencies": {
"@workflow/world-postgres": "5.0.0-beta.40",
"pg": "8.20.0"
}
}
race.mjs
import assert from "node:assert/strict";
import { fork } from "node:child_process";
import { once } from "node:events";
import { readFileSync } from "node:fs";
import { Pool } from "pg";
const database = new URL(process.env.WORKFLOW_POSTGRES_URL ?? "http://invalid");
if (
!["127.0.0.1", "localhost", "[::1]"].includes(database.hostname) ||
!database.pathname.startsWith("/b2_repro_")
) {
throw new Error("Requires a dedicated local b2_repro_ database");
}
const processCount = 12;
const token = `hook-token-race-${Date.now()}`;
const children = [];
const results = [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
const pool = new Pool({ connectionString: database.href, query_timeout: 5000 });
try {
await Promise.all(
Array.from({ length: processCount }, async () => {
const worker = fork(new URL("race-worker.mjs", import.meta.url), [], {
execArgv: [],
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
const exited = once(worker, "exit", { signal: controller.signal });
// Observe exit immediately, including setup failure before the ready message.
const ready = Promise.race([
once(worker, "message", { signal: controller.signal }),
exited.then(() => {
throw new Error("Worker exited before becoming ready");
}),
]);
children.push({ exited, worker });
worker.on("message", (message) => {
if (!message.ready) {
results.push(message);
}
});
const [message] = await ready;
if (!message.ready) {
throw new Error("Worker setup failed");
}
})
);
const completed = Promise.all(children.map((child) => child.exited));
if (process.argv.includes("--sequential")) {
for (const child of children) {
child.worker.send({ token });
// eslint-disable-next-line no-await-in-loop -- This is the serial positive control.
await child.exited;
}
} else {
for (const child of children) {
child.worker.send({ token });
}
}
await completed;
if (
results.length !== processCount ||
results.some((result) => result.error)
) {
throw new Error("Incomplete or failed worker results; inconclusive");
}
const counts = {};
for (const result of results) {
counts[result.eventType] = (counts[result.eventType] ?? 0) + 1;
}
const rows = await pool.query(
"select run_id from workflow.workflow_hooks where token = $1",
[token]
);
const { version } = JSON.parse(
readFileSync("node_modules/@workflow/world-postgres/package.json", "utf-8")
);
const evidence = {
counts,
distinctOwners: new Set(rows.rows.map((row) => row.run_id)).size,
hookRows: rows.rowCount,
package: `@workflow/world-postgres@${version}`,
processes: processCount,
returnedHooks: results.filter((result) => result.hasHook).length,
};
console.log(JSON.stringify(evidence, null, 2));
assert.equal(
counts.hook_created,
1,
"exactly one caller must own a hook token"
);
assert.equal(
counts.hook_conflict,
processCount - 1,
"losers must receive conflicts"
);
assert.equal(evidence.returnedHooks, 1);
assert.equal(evidence.hookRows, 1);
assert.equal(evidence.distinctOwners, 1);
} catch (error) {
console.error(error);
process.exitCode = error instanceof assert.AssertionError ? 1 : 2;
} finally {
clearTimeout(timeout);
for (const child of children) {
if (child.worker.exitCode === null) {
child.worker.kill("SIGTERM");
}
}
await Promise.allSettled(children.map((child) => child.exited));
await pool.end();
}
race-worker.mjs
import { once } from "node:events";
import { createWorld } from "@workflow/world-postgres";
const world = createWorld({
connectionString: process.env.WORKFLOW_POSTGRES_URL,
});
try {
const created = await world.events.create(null, {
eventData: {
deploymentId: "hook-token-race-repro",
input: [],
workflowName: "hook-token-race-repro",
},
eventType: "run_created",
});
process.send({ ready: true });
const [{ token }] = await once(process, "message");
const result = await world.events.create(created.run.runId, {
correlationId: `hook-${created.run.runId}`,
eventData: { token },
eventType: "hook_created",
});
process.send({
eventType: result.event.eventType,
hasHook: result.hook !== undefined,
runId: created.run.runId,
});
} catch (error) {
process.send({
error: error instanceof Error ? error.message : String(error),
});
process.exitCode = 1;
} finally {
await world.close();
process.disconnect();
}
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read #1970 first, then run the supplied race.mjs and race-worker.mjs fixture against a fresh local PostgreSQL database using the documented setup command. Done means the concurrent run reports one hook_created, eleven hook_conflict results, one returned hook, one persisted row, and one distinct owner, with rollout and regression behavior clarified.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, postgresql, typescript
- Domain
- backend, databases, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 45/100