Repository navigation
Concurrent migrate on a fresh database: waiting runs replay the initial plan after the winner commits ("type … already exists") #30658
Description
Versions
@prisma/orm-postgres 8.0.0-rc.16, prisma CLI 8.0.0-rc.21, PostgreSQL 17.5, Node.js 24.21.
Summary
When several processes migrate the same fresh (empty) database at the same time, one succeeds and the others fail with MIGRATION.RUNNER_FAILED:
Operation createNativeEnumType.Status failed during execution: create enum type "Status"
why: type "Status" already exists
The failed runs roll back and a re-run is a no-op, so the database ends up consistent. But any concurrent first-time migrate fails: parallel provisioning jobs (one database per tenant), two CI jobs sharing a database, or replicas that migrate on boot. It happens through the control client and through prisma db migrate.
Reproduction
prisma/contract.prisma:
// use prisma-8
native_enum Status {
OPEN = "OPEN"
DONE = "DONE"
}
model Item {
id Int @id @default(autoincrement())
status pg.enum(Status) @default("OPEN")
@@map("items")
}prisma.config.ts uses definePrismaConfig({ orm: ormConfig({ contract: './prisma/contract.prisma', migrations: { dir: './prisma/migrations' }, db: { connection: process.env.DATABASE_URL } }) }). Then prisma contract emit and prisma migration plan --name init.
race.mjs:
import { readFileSync } from 'node:fs'
import { createPostgresControlClient } from '@prisma/orm-postgres/control'
import pg from 'pg'
const contract = JSON.parse(readFileSync('./prisma/contract.json', 'utf8'))
const db = `race_${Date.now()}`
const admin = new pg.Client({ connectionString: process.env.ADMIN_URL }) // …/postgres
await admin.connect()
await admin.query(`CREATE DATABASE "${db}"`)
const url = new URL(process.env.ADMIN_URL)
url.pathname = `/${db}`
const results = await Promise.all(
[1, 2, 3].map(async () => {
const client = createPostgresControlClient({ connection: url.toString() })
try {
const r = await client.migrate({ contract, migrationsDir: './prisma/migrations' })
return r.ok ? 'ok' : `${r.failure.code}: ${r.failure.why}`
} finally {
await client.close()
}
}),
)
console.log(results)
await admin.query(`DROP DATABASE "${db}" WITH (FORCE)`)
await admin.end()Output, three runs out of three:
[ 'ok',
'RUNNER_FAILED: type "Status" already exists',
'RUNNER_FAILED: type "Status" already exists' ]
The CLI behaves the same: three parallel prisma db migrate --db <fresh database url> give one "ok": true and two MIGRATION.RUNNER_FAILED … already exists.
Cause (from reading the published dist)
PostgresMigrationRunner.executeOnConnection in @prisma/orm-target-postgres (dist/control-*.mjs) takes the marker lock, re-reads the marker, and then decides whether to run the plan:
await this.acquireLock(driver);
// …
const existingMarker = await this.family.readMarker({ driver, space });
// …
const markerAtDestination = this.markerMatchesDestination(existingMarker, options.plan);
const isSelfEdge = options.plan.origin?.storageHash === options.plan.destination.storageHash;
const skipOperations = markerAtDestination && options.plan.origin != null && !isSelfEdge;A plan made against an empty database has plan.origin == null, so skipOperations stays false even when the marker read under the lock is already at the destination. The waiting runs then execute the initial operations a second time.
Expected
A run that finds the marker already at its destination after taking the lock reports "already up to date", as it does when plan.origin is set.
Workaround
Retrying on RUNNER_FAILED: the retry re-plans, sees the marker, and does nothing.
Activity
Ritwikcp-ctrl commentedon Oct 11, 2026
so , i see the issue :
that must be :
This returns it true(skip the operation and responds success that the migration is done already).
As the three simultaneous process ->
Ritwikcp-ctrl commentedon Oct 11, 2026
I went through every scenario to make sure this doesn't break anything :-
Process A (first one, empty DB): markerAtDestination is false, so it runs the operations.
Process B/C (waiting, DB already migrated): markerAtDestination is true, isSelfEdge is false, so it skips.
I'll open a PR with the fix and a regression test that covers the concurrent migration scenario. Just wanted to confirm the diagnosis here first in case there was a reason for that origin != null check that I'm missing. If not, I'll get the PR up shortly.