Skip to content

Concurrent migrate on a fresh database: waiting runs replay the initial plan after the winner commits ("type … already exists") #30658

Open

Description

@pawelkrystkiewicz

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

  1. Ritwikcp-ctrl commented on Oct 11, 2026

    @Ritwikcp-ctrl

    so , i see the issue :

     const skipOperations = markerAtDestination && options.plan.origin != null && !isSelfEdge;

    that must be :

       const skipOperatons  = markerAtDestination && ! isSelfEdge;

    This returns it true(skip the operation and responds success that the migration is done already).

    As the three simultaneous process ->

     origin == Null
  2. Ritwikcp-ctrl commented on Oct 11, 2026

    @Ritwikcp-ctrl

    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.

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