Skip to main content
DevOps ve Altyapı•22 dk okuma

Prisma ile Kesintisiz Migration: Expand-Contract ve Rollback Dosyası

Mustafa Kürşad BaşerMustafa Kürşad BAŞER
•
Prisma ile Kesintisiz Migration: Expand-Contract ve Rollback Dosyası

Şema aynı anda iki sürüme hizmet eder

Özet (TL;DR)

  • Deploy sırasında eski ve yeni kod bir süre aynı veritabanına bağlıdır. Her migration, bir önceki kod sürümüyle de çalışmak zorunda.
  • Prisma migration.sql dosyasını tek sorgu metni olarak gönderir, PostgreSQL de onu tek transaction'da çalıştırır. CREATE INDEX CONCURRENTLY bu yüzden kendi klasörünü ister.
  • Sırada bekleyen bir ACCESS EXCLUSIVE talebi arkasındaki sıradan SELECT sorgularını da durdurur. DDL içeren her migration SET LOCAL lock_timeout ile başlamalı.
  • CHECK ... NOT VALID, VALIDATE CONSTRAINT ve SET NOT NULL ayrı migration'lara bölünür. Aynı dosyada kalırlarsa tablo doğrulama boyunca kilitli kalır.
  • migrate diff ile üretilen down dosyası şemayı geri getirir, veriyi getirmez. DROP COLUMN sonrasındaki rollback boş bir sütun açar.

prisma migrate deploy yeşil bitiyor. Yeni sürüm sunuculara yayılırken loglara tek bir satır düşüyor: column "name" does not exist. Hatayı atan yeni sürüm değil. Henüz kapanmamış eski süreçler, az önce adı değiştirilen sütunu hâlâ eski adıyla soruyor.

Migration doğruydu, sıralama yanlıştı. Rolling deploy'da, PM2'nin cluster modundaki reload komutunda ya da iki sunucunun arka arkaya güncellendiği her kurulumda eski ve yeni kod bir süre aynı veritabanına bağlı kalır. O süre birkaç saniye bile olsa şema, iki sürümün de beklentisini karşılamak zorundadır.

HeyPilot'ta Prisma ile PostgreSQL kullanıyorum ve her şema değişikliğinin yanında bir rollback dosyası duruyor, KVKK için yazılan telefon şifreleme katmanı dahil. Aşağıda bu alışkanlığı bir sütun yeniden adlandırması üzerinden açıyorum. Örnekleri Docker'da PostgreSQL 17.7 ve Prisma 6.19.2 ile çalıştırdım, sürüme bağlı yerleri Prisma 7.2.0 ile de denedim.

migrate deploy neyi bilir, neyi bilmez

migrate deploy dar bir iş yapar. _prisma_migrations tablosuna bakar, uygulanmamış klasörleri ad sırasıyla bulur ve her birinin migration.sql dosyasını çalıştırır. İki deploy çakışmasın diye bir advisory lock da alır, PostgreSQL loglarında SELECT pg_advisory_lock(72707369) olarak görünüyor. Hangi kod sürümünün ayakta olduğunu ise bilmez.

Sıralamayı siz kurarsınız. Önce migration çıkıyorsa şema eski kodla uyumlu olmalı, önce kod çıkıyorsa yeni kod eski şemayla çalışabilmeli. İkisi de olmuyorsa kesinti kaçınılmazdır. Semantik versiyonlamadaki geriye dönük uyumluluk fikri burada da geçerli: her migration, çalışan koda karşı minor bir sürüm gibi davranmalı.

Prisma sizi kolay yola da itebilir. Şemada name alanını fullName yapıp @map("full_name") eklerseniz Prisma bunu yeniden adlandırma olarak okumaz:

bash
npx prisma migrate diff \  --from-schema-datamodel prisma/semalar/0-baslangic.prisma \  --to-schema-datamodel prisma/semalar/x-dogrudan-ad-degistir.prisma \  --script
sql
-- AlterTableALTER TABLE "leads" DROP COLUMN "name",ADD COLUMN     "full_name" TEXT NOT NULL;

Dolu bir tabloda bu dosya zaten çalışmaz. migrate dev de 200.000 satırlık tabloda durumu açıkça söylüyor: full_name default değeri olmadan eklenemez ve name düşürülürse 200.000 dolu değer gider. Elle RENAME COLUMN yazmak veriyi korur ama eski kodu anında kırar. İki kötü seçeneğin yanında üçüncü bir yol var: değişikliği parçalara bölmek.

Expand-contract: tek değişikliği altı adıma bölmek

Danilo Sato, martinfowler.com'daki ParallelChange yazısında deseni üç evreyle tarif ediyor: expand, migrate, contract. Geriye uyumsuz bir değişikliği önce yeni yapıyı ekleyip eskisiyle yan yana yaşatarak yaparsınız (expand), herkesi yeniye geçirirsiniz (migrate), en sonunda da eskisini kaldırırsınız (contract). Ambler ve Sadalage Refactoring Databases'te aynı fikri veritabanı tarafından anlatıyor ve eski ile yeni şemanın birlikte yaşadığı ara döneme geçiş dönemi (transition period) diyor.

Bir sütunu yeniden adlandırırken bu üç evre altı somut adıma açılıyor:

AdımVeritabanıUygulamaGeri dönüş
1. Sütun ekleADD COLUMN full_name, boş geçilebilirDeğişmez, name okur ve yazarDROP COLUMN full_name, kayıp yok
2. Dual writeDeğişmezHer yazma iki sütuna giderÖnceki sürüme dönülür
3. BackfillEski satırlar dolar, full_name NOT NULL olur, indeks kurulurDeğişmezKısıt ve indeks kaldırılır
4. Okumayı taşıDeğişmezfull_name okur, iki sütuna da yazarÖnceki sürüme dönülür
5. Eskisini bırakname boş geçilebilir olurYalnızca full_name yazarname yeniden doldurulmalı
6. KaldırDROP COLUMN nameDeğişmezSütun geri gelir, veri gelmez
name sütunundan full_name sütununa geçişin altı adımı: sütun ekle, dual write, backfill, okumayı taşı, eskisini bırak, kaldır. İlk beş adımda veri kaybı yok, son adım geri alınamaz
Her kart ayrı bir deploy. Veri kaybı yalnızca son adımda başlar.

Her satır ayrı bir deploy. İki kural değişmiyor. Dual write her yerde açılmadan backfill başlamaz, yoksa backfill'den sonra eski kodun eklediği satırlar yine boş full_name ile gelir. Okumayı taşıyan sürüm her yerde çalışmadan da eski sütun boşalmaya başlamaz.

PostgreSQL hangi komutta neyi kilitler

Migration kaynaklı kesintilerin çoğu yanlış SQL'den değil yanlış kilitten çıkar. ALTER TABLE'ın belgelenmiş varsayılanı ACCESS EXCLUSIVE, yani tabloya her erişimi durduran en sert kilit. Bazı alt komutlar daha hafifiyle yetinir. Aşağıdaki tabloyu her komutu bir transaction içinde çalıştırıp pg_locks görünümüne bakarak çıkardım:

KomutKilitNeyi durdurur
SELECTAccessShareLockYalnızca ACCESS EXCLUSIVE isteyeni
UPDATE, INSERT, DELETERowExclusiveLockSHARE ve üstünü
ADD COLUMN (sabit default ile de)AccessExclusiveLockHer şeyi, kısa süre
ADD CONSTRAINT ... CHECK ... NOT VALIDAccessExclusiveLockHer şeyi, kısa süre
VALIDATE CONSTRAINTShareUpdateExclusiveLockBaşka DDL'i ve VACUUM'u
SET NOT NULL, DROP NOT NULLAccessExclusiveLockHer şeyi, tarama sürdükçe
RENAME COLUMN, DROP COLUMNAccessExclusiveLockHer şeyi, kısa süre
CREATE INDEXShareLockBütün yazmaları, kurulum boyunca
CREATE INDEX CONCURRENTLYShareUpdateExclusiveLockBaşka DDL'i ve VACUUM'u

ADD COLUMN komutunun kısa sürmesinin sebebi, PostgreSQL 11'den beri sabit bir default değerin tabloyu yeniden yazdırmaması. clock_timestamp() gibi volatile bir default ya da ikili uyumlu (binary coercible) olmayan bir tip değişikliği ise tabloyu ve indeksleri baştan yazar. O süre boyunca da ACCESS EXCLUSIVE elde tutulur.

Kısa kilit, uzun kuyruk

"Kısa süre" yanıltabilir. ADD COLUMN kilidi milisaniyelerce tutar ama önce onu alması gerekir. Tabloda uzun bir rapor sorgusu ya da commit edilmeyi unutmuş bir oturum varsa migration onu bekler. Sırada duran bir ACCESS EXCLUSIVE talebi de arkasından gelen herkesi durdurur, en sıradan okuma dahil. Üç oturumla görmek kolay:

bash
S() { PGAPPNAME=$1 psql -U postgres -Atq; }   # oturuma ad veren kısayol # A: uzun süren bir okuma, transaction açık kalıyorecho "BEGIN; SELECT count(*) FROM leads; SELECT pg_sleep(8); COMMIT;" | S A_rapor &sleep 1# B: migrationecho "ALTER TABLE leads ADD COLUMN email text;" | S B_migration &sleep 1# C: API'den gelen sıradan bir okumaecho "SELECT name FROM leads WHERE id = 42;" | S C_api &sleep 1psql -U postgres < kuyruk_gozlem.sql
sql
SELECT a.application_name AS oturum, l.mode, l.granted,       pg_blocking_pids(a.pid) AS bekledigi, a.pid, left(a.query, 42) AS sorguFROM pg_locks lJOIN pg_stat_activity a ON a.pid = l.pidWHERE l.relation = 'leads'::regclassORDER BY a.application_name; --    oturum    |        mode         | granted | bekledigi | pid |                  sorgu-- -------------+---------------------+---------+-----------+-----+--------------------------------------------  A_rapor     | AccessShareLock     | t       | {}        | 103 | SELECT pg_sleep(8);--  B_migration | AccessExclusiveLock | f       | {103}     | 112 | ALTER TABLE leads ADD COLUMN email text;--  C_api       | AccessShareLock     | f       | {112}     | 119 | SELECT name FROM leads WHERE id = 42;

C_api satırına bakın. İstediği AccessShareLock, raporun kilidiyle çakışmıyor. Yine de bekliyor, çünkü pg_blocking_pids ona 112 numaralı süreci, yani migration'ı gösteriyor. Rapor sekiz saniye sürerse API sekiz saniye cevap vermez. Kırk dakika sürerse kırk dakika.

Üç oturumluk kilit kuyruğu: rapor sorgusu AccessShareLock tutuyor, migration AccessExclusiveLock için onu bekliyor, API okuması da migration'ın arkasında sıraya giriyor
API'nin kilidi raporla çakışmaz ama migration'ın arkasında bekler.

lock_timeout ve Prisma'da SET LOCAL

Çaresi migration'a bir bekleme sınırı koymak. lock_timeout dolunca komut iptal edilir, kuyruk açılır, siz de migration'ı daha sakin bir anda yeniden denersiniz:

sql
SET lock_timeout = '2s';ALTER TABLE leads ADD COLUMN phone text;-- ERROR:  canceling statement due to lock timeout-- Time: 2001.583 ms (00:02.002)

Prisma'da bu satır migration dosyasının başına SET LOCAL olarak girer. migrate deploy bekleyen bütün migration'ları tek bağlantı üzerinden çalıştırıyor. Birinci dosyaya düz SET lock_timeout = '2s' koyduğumda ikinci dosya da 2s gördü, ayar sızdı. SET LOCAL ile ikinci dosya 0 gördü. Prisma 6.19.2 ile 7.2.0 aynı davrandı.

Süre dolarsa Prisma P3018 hatasıyla durur, veritabanı hata kodu olarak da 55P03 görünür. Dosya tek transaction'da çalıştığı için yarım kalan bir değişiklik olmaz. prisma migrate resolve --rolled-back <ad> ile kaydı işaretleyip deploy'u tekrar çalıştırmanız yeterli.

Adım adım: name sütunundan full_name sütununa

1. Sütunu ekleyin, down dosyasını da hemen yazın

Yeni alan şemaya boş geçilebilir olarak girer, eskisine dokunulmaz. Migration ile rollback dosyasını aynı anda, iki şema dosyası arasındaki farktan üretiyorum. Her adımın şeması prisma/semalar/ altında ayrı bir dosya olarak duruyor. Prisma 6'da:

bash
D=prisma/migrations/20260113090000_lead_full_name_eklemkdir -p $D npx prisma migrate diff \  --from-schema-datamodel prisma/semalar/0-baslangic.prisma \  --to-schema-datamodel prisma/semalar/1-genislet.prisma \  --script > $D/migration.sql npx prisma migrate diff \  --from-schema-datamodel prisma/semalar/1-genislet.prisma \  --to-schema-datamodel prisma/semalar/0-baslangic.prisma \  --script > $D/down.sql
sql
-- migration.sql (ilk satır elle eklendi)SET LOCAL lock_timeout = '3s'; -- AlterTableALTER TABLE "leads" ADD COLUMN     "full_name" TEXT; -- down.sql-- AlterTableALTER TABLE "leads" DROP COLUMN "full_name";

İkinci komutta yalnızca yön tersine dönüyor. down.sql aynı klasörde durabilir, migrate deploy sadece migration.sql dosyasını okuyor ve yanındaki dosyaya hiç bakmıyor.

Bilgi

Prisma 7'de (7.0.0, 19 Kasım 2025) bu flag'ler değişti. --from-schema-datamodel ve --to-schema-datamodel yerine --from-schema ve --to-schema geldi. --from-url, --from-schema-datasource ve --shadow-database-url kalktı, bağlantı prisma.config.ts dosyasından --from-config-datasource ile okunuyor. Prisma 7.2.0 eski flag'i görünce "Please use --[from/to]-schema instead" diyerek duruyor, 6.19.2 ise yeni flag'i tanımıyor. CI betiğiniz hangi sürüme bağlıysa ona göre yazın.

2. İki sütuna birden yazın

Bu adımda şema değişmez, kod değişir. Prisma Client'ın query extension'ı dual write'ı tek bir yerde toplamaya imkân veriyor:

typescript
import { PrismaClient } from "@prisma/client"; // Geçiş dönemi: her yazma iki sütuna da gider.export const prisma = new PrismaClient().$extends({  query: {    lead: {      async create({ args, query }) {        const ad = args.data.fullName ?? args.data.name;        args.data = { ...args.data, name: ad, fullName: ad };        return query(args);      },      async update({ args, query }) {        const ad = args.data.fullName ?? args.data.name;        if (typeof ad === "string") {          args.data = { ...args.data, name: ad, fullName: ad };        }        return query(args);      },    },  },});
bash
$ npx tsx ciftyaz-dene.tsAyşe Yılmaz | Ayşe YılmazAyşe Demir | Ayşe Demir

Deneme betiği kaydı yalnızca name ile açıp yalnızca fullName ile güncelliyor, iki sütun da her seferinde aynı değeri taşıyor. upsert, createMany ve updateMany de yazar, geçiş süresince onları da ekleyin. $executeRaw ile yazan yerler extension'dan hiç geçmez. Prisma 7'de istemci new PrismaClient({ adapter }) ile kuruluyor, extension bloğu 7.2.0'da değişmeden çalıştı.

3. Backfill: batch'ler hâlinde ve migration dışında

En kötü yol, tek bir UPDATE leads SET full_name = name yazıp migration dosyasına koymak. Dosya tek transaction olduğu için her satırın kilidi sonuna kadar tutulur, WAL bir anda şişer ve replikalar geride kalır. Backfill, Twelve-Factor'ın admin process ilkesindeki gibi uygulamayla aynı sürümden ayrı bir betik olarak çalışmalı:

typescript
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient();const PARTI = 5_000n;const MAKS_GECIKME = 64n * 1024n * 1024n; // replikada en fazla 64 MB geride kalmaconst bekle = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function replikaGecikmesi(): Promise<bigint> {  const [r] = await prisma.$queryRaw<{ bayt: bigint | null }[]>`    SELECT max(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn))::bigint AS bayt    FROM pg_stat_replication`;  return r?.bayt ?? 0n;} const [{ son }] = await prisma.$queryRaw<{ son: bigint }[]>`  SELECT coalesce(max(id), 0)::bigint AS son FROM leads`; let toplam = 0;for (let alt = 0n; alt < son; alt += PARTI) {  while ((await replikaGecikmesi()) > MAKS_GECIKME) await bekle(1_000);  toplam += await prisma.$executeRaw`    UPDATE leads SET full_name = name    WHERE id > ${alt} AND id <= ${alt + PARTI}      AND full_name IS NULL`;  await bekle(50);} const [{ kalan }] = await prisma.$queryRaw<{ kalan: bigint }[]>`  SELECT count(*) AS kalan FROM leads WHERE full_name IS NULL`;console.log(`${toplam} satır dolduruldu, boş kalan: ${kalan}`);await prisma.$disconnect();
bash
$ npx tsx backfill.ts200000 satır dolduruldu, boş kalan: 0

İşi üç ayrıntı yapıyor. Batch'ler birincil anahtar aralığıyla seçiliyor, WHERE full_name IS NULL LIMIT 5000 dolmuş satırların üzerinden her turda yeniden geçerdi. Her UPDATE kendi transaction'ında commit ediliyor, kilitler en fazla 5.000 satırda kalıyor. Her batch'ten önce pg_stat_replication okunuyor ve replika eşikten fazla gerideyse betik bekliyor.

Uyarı

Gecikme kontrolü yetkiye bağlı. PostgreSQL 17 kaynağında pg_stat_replication, pg_read_all_stats yetkisi olmayan rollere WAL sender satırının yalnızca pid değerini gösteriyor. Öteki sütunlar boş geliyor, yukarıdaki betik de bu durumda gecikmeyi sıfır sanıyor. Backfill rolüne pg_monitor ya da pg_read_all_stats verin veya boş değeri hata olarak ele alın.

Veriyi dönüştüren geçişlerde iskelet aynı kalır, değişen tek şey döngünün içi. Diyelim ki düz metin telefon numaralarını şifreli bir sütuna taşıyorsunuz. Anahtar uygulamada durduğu için UPDATE SQL'de yazılamaz. Döngü her batch'i okur, uygulamada şifreler ve geri yazar.

NOT NULL'u tabloyu kilitlemeden eklemek

Backfill bitti, artık full_name zorunlu olabilir. Prisma'nın bu değişiklik için ürettiği SQL tek satır: ALTER TABLE "leads" ALTER COLUMN "full_name" SET NOT NULL. Bu komut ACCESS EXCLUSIVE alır ve boş değer olmadığını kanıtlamak için bütün tabloyu tarar. Tarama sürdükçe tabloya okuma da yazma da gitmez.

PostgreSQL 12'den beri daha iyi bir yol var. Boş değer olamayacağını kanıtlayan geçerli bir CHECK kısıtı varsa SET NOT NULL taramayı atlıyor. NOT VALID ile eklenen kısıt yalnızca yeni yazmaları denetler. VALIDATE CONSTRAINT eski satırları tarar ama SHARE UPDATE EXCLUSIVE ile yetinir, okuma ve yazma sürer. debug1 seviyesinde PostgreSQL ne yaptığını söylüyor:

sql
SET client_min_messages = debug1; -- Doğrudan: tablo taranırALTER TABLE leads ALTER COLUMN full_name SET NOT NULL;-- DEBUG:  verifying table "leads" -- Güvenli yolALTER TABLE leads ADD CONSTRAINT leads_full_name_not_null  CHECK (full_name IS NOT NULL) NOT VALID;ALTER TABLE leads VALIDATE CONSTRAINT leads_full_name_not_null;-- DEBUG:  verifying table "leads"ALTER TABLE leads ALTER COLUMN full_name SET NOT NULL;-- DEBUG:  existing constraints on column "leads.full_name" are sufficient to prove that it does not contain nullsALTER TABLE leads DROP CONSTRAINT leads_full_name_not_null;

Tuzak Prisma tarafında. Bu dört satırı tek bir migration.sql dosyasına yazarsanız hepsi aynı transaction'da çalışır ve ilk satırın aldığı ACCESS EXCLUSIVE transaction bitene kadar bırakılmaz. Doğrulama yine bütün tabloyu tarar, üstelik bu kez tablo kilitliyken:

sql
BEGIN;ALTER TABLE leads ADD CONSTRAINT leads_full_name_not_null  CHECK (full_name IS NOT NULL) NOT VALID;ALTER TABLE leads VALIDATE CONSTRAINT leads_full_name_not_null;SELECT string_agg(mode, ', ' ORDER BY mode) FROM pg_locksWHERE relation = 'leads'::regclass AND pid = pg_backend_pid();-- AccessExclusiveLock, ShareUpdateExclusiveLockROLLBACK;

Bu yüzden adım üç klasöre bölünüyor ve her dosya kendi transaction'ında çalışıyor:

sql
-- 20260114090000_lead_full_name_check/migration.sqlSET LOCAL lock_timeout = '3s'; ALTER TABLE "leads"  ADD CONSTRAINT "leads_full_name_not_null"  CHECK ("full_name" IS NOT NULL) NOT VALID; -- 20260114090100_lead_full_name_validate/migration.sqlALTER TABLE "leads" VALIDATE CONSTRAINT "leads_full_name_not_null"; -- 20260114090200_lead_full_name_not_null/migration.sqlSET LOCAL lock_timeout = '3s'; -- Geçerli CHECK kısıtı sayesinde tablo taranmazALTER TABLE "leads" ALTER COLUMN "full_name" SET NOT NULL;ALTER TABLE "leads" DROP CONSTRAINT "leads_full_name_not_null";

Prisma CHECK kısıtlarını modellemediği için migrate diff onları göremez, ilk iki dosyanın down'u elle yazılır. Prisma'nın down migration belgesi de elle eklenen SQL'in tersini elle eklemenizi istiyor. Üçüncü dosyanın down'u yine migrate diff'ten çıkıyor.

Bilgi

PostgreSQL 18'de (25 Eylül 2025) NOT NULL kısıtı da NOT VALID eklenebiliyor: ADD CONSTRAINT leads_full_name_not_null NOT NULL full_name NOT VALID, ardından VALIDATE CONSTRAINT. 18.1'de doğrulama yalnızca ShareUpdateExclusiveLock aldı ve sütun doğrudan not null oldu. Bölme kuralı aynı: iki komut, iki migration.

İndeks: CONCURRENTLY kendi klasöründe

Okumalar taşınmadan indeks de kurulmalı. Prisma'nın @@index([fullName]) için ürettiği CREATE INDEX kurulum boyunca ShareLock tutup yazmaları durdurur. CONCURRENTLY sürümü SHARE UPDATE EXCLUSIVE ile yetinir. Denemede indeks açık bir yazma transaction'ını beklerken başka bir oturumun UPDATE sorgusu 1,5 milisaniyede bitti.

Belgedeki kısıt açık: CONCURRENTLY transaction bloğu içinde çalışmaz. log_statement = 'all' ile baktığımda ne Prisma 6.19.2 ne 7.2.0 BEGIN gönderiyordu. Dosya tek sorgu metni olarak gidiyor ve PostgreSQL'in protokol belgesine göre çok ifadeli basit sorgu tek transaction sayılıyor. Önüne SET LOCAL lock_timeout koymak bile yetiyor:

sql
-- 20260114090300_lead_full_name_indeks/migration.sqlSET LOCAL lock_timeout = '3s';CREATE INDEX CONCURRENTLY "leads_full_name_idx" ON "leads"("full_name");
bash
$ npx prisma migrate deployApplying migration `20260114090300_lead_full_name_indeks`Error: P3018Database error code: 25001ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

ADD COLUMN ile yan yana yazdığımda da aynı hata geldi ve sütun eklenmedi, dosya bütün olarak geri alındı. Çözüm, komutu tek başına bir klasöre koymak. Tek ifade kalınca çalışıyor, yorum satırları sayılmıyor. Down dosyası da tek satır: DROP INDEX CONCURRENTLY IF EXISTS "leads_full_name_idx";.

4 ve 5. Okumayı taşıyın, sonra eski sütunu bırakın

Bundan sonrası iki kod deploy'u ile küçük bir migration. Önce okumalar full_name sütununa geçer, yazmalar hâlâ iki sütuna gider. Bu sürümde bir sorun çıkarsa bir önceki sürüme dönmenin bedeli yoktur, çünkü name hâlâ güncel.

Eski sütuna yazmayı bırakmadan önce onun NOT NULL kısıtı kalkmalı, yoksa name göndermeyen ilk INSERT hata verir. DROP NOT NULL kısa bir kilit alır, tarama yapmaz. migrate diff'in ürettiği down dosyası SET NOT NULL komutunu geri koyar, ama yalnızca boş name oluşmadan önce. Sonrasında denediğimde prisma db execute Null constraint failed: (name) hatasıyla durdu.

Bu noktadan sonra name artık güncel değil. Kodu name okuyan bir sürüme geri almak hata vermez ama yeni satırları eksik gösterir. Geri dönüşün maliyeti ilk kez burada sıfırdan büyük oluyor.

6. Contract: eski sütunu kaldırın

Son migration tek satır: ALTER TABLE "leads" DROP COLUMN "name";. PostgreSQL sütunu diskten hemen silmez, yalnızca SQL'e görünmez yapar, o yüzden komut hızlıdır. Veriye ise artık hiçbir sorguyla ulaşamazsınız.

Rollback dosyası neyi geri alır

Bu son migration için migrate diff şu down dosyasını üretiyor:

sql
-- AlterTableALTER TABLE "leads" ADD COLUMN     "name" TEXT;

Sütun geri geliyor, içi gelmiyor. Dosyayı prisma db execute ile çalıştırdığımda tablodaki 200.001 satırın hepsinde name boştu. Expand-contract'ın burada neden işe yaradığı belli oluyor: veri full_name sütununda duruyor. Bu migration'ın down dosyasına name sütununu full_name'den yeniden dolduran satırı elle ekliyorum. Ekledikten sonra aynı deneme sıfır boş satırla bitti:

sql
-- AlterTableALTER TABLE "leads" ADD COLUMN     "name" TEXT; -- Elle eklendi: name'i full_name'den yeniden doldur.-- Büyük tabloda bunu da batch'lere bölün.UPDATE "leads" SET "name" = "full_name";

Başka hiçbir yerde kopyası olmayan bir sütuna DROP COLUMN uyguladığınızda down dosyası yalnızca şemayı kurtarır. DROP TABLE, USING ile değeri kırpan tip değişiklikleri ve satır silen veri migration'ları da aynı sınıfta. Bunların geri dönüş planı rollback dosyası değil, yedekten geri yüklemedir.

migrate resolve yalnızca başarısız migration için

Prisma belgelerindeki down migration akışı başarısız bir migration içindir: prisma db execute --file down.sql ile şemayı geri alırsınız, prisma migrate resolve --rolled-back <ad> ile kaydı işaretlersiniz. Başarıyla uygulanmış bir migration'da ikinci komut reddediliyor:

bash
$ npx prisma migrate resolve --rolled-back 20260116090000_lead_name_kaldirError: P3012Migration `20260116090000_lead_name_kaldir` cannot be rolled back because it is not in a failed state.

Down dosyasını yine de db execute ile çalıştırırsanız şema geri döner ama _prisma_migrations migration'ı uygulanmış saymaya devam eder. prisma migrate status da "Database schema is up to date!" der, çünkü tarihçeye bakar. Temiz yol, down dosyasını yeni bir migration klasörüne kopyalayıp ileri doğru uygulamak. Rollback dosyasının değeri de burada: geri dönüşün SQL'i panik anında değil, değişiklik yazılırken düşünülmüş olur.

Sonuç

Bir migration'ı yazmadan önce iki soru sorun. Bu şema şu an çalışan kodla da çalışıyor mu? Bu adımın down dosyası veriyi de geri getiriyor mu? İlkine hayır diyorsanız değişikliği bölün. İkincisine hayır diyorsanız o adım bir contract adımıdır ve takvimde en sona gider.

Benim kuralım şu: down dosyası boş bir sütun açan migration, eski verinin başka bir sütunda ya da geri yüklenebilir bir yedekte durduğu doğrulanmadan çalıştırılmaz.

Sıkça Sorulan Sorular

Sütun tipini değiştirirken de aynı desen mi gerekir?
Tipe bağlı. Eski tip yeni tipe ikili uyumluysa (binary coercible) tablo yeniden yazılmaz. Denemede varchar(50) sütunu varchar(100) ve text yapınca tablonun pg_relation_filenode değeri aynı kaldı. integer sütunu bigint yapınca değişti, yani tablo ACCESS EXCLUSIVE altında baştan yazıldı. Büyük tabloda bu dönüşüm de expand-contract ister.
Foreign key eklemek hangi tabloları kilitler?
ADD FOREIGN KEY iki tabloda da SHARE ROW EXCLUSIVE alır, iki tarafta da yazmalar durur. NOT VALID ile eklenince satırlar kontrol edilmediği için bu kısa sürer. Ardından gelen VALIDATE CONSTRAINT kısıtın tablosunda ShareUpdateExclusiveLock, bağlanılan tabloda yalnızca RowShareLock aldı. Prisma ilişki için kısıtı doğrudan geçerli yazar. Büyük tablolarda migration dosyasındaki kısıta NOT VALID ekleyip doğrulamayı ayrı migration'a taşıyın.
`CREATE INDEX CONCURRENTLY` yarıda kalırsa ne olur?
Geride geçersiz (invalid) bir indeks kalır. Sorgular onu kullanmaz ama her yazmada güncellenmeye devam eder. Belgenin önerdiği yol indeksi silip yeniden kurmak, DROP INDEX CONCURRENTLY de tek başına bir dosyada çalışmalı. IF NOT EXISTS burada işe yaramıyor: denemede komut "already exists, skipping" deyip geçti, indeks geçersiz kaldı.
`lock_timeout` yerine `statement_timeout` kullanılamaz mı?
statement_timeout komutun toplam süresini sınırlar, beklemeyle çalışmayı ayırmaz. Hiçbir şeyi engellemeden tarayan uzun bir VALIDATE CONSTRAINT de iptal edilir. lock_timeout yalnızca kilidi beklerken geçen süreyi sayar. Korunmak istediğiniz şey kuyruk olduğu için doğru ayar lock_timeout, statement_timeout ise ikinci emniyet olarak kalabilir.

Kaynakça

Bu yazıyı paylaş

Mustafa Kürşad Başer
Yazar

Mustafa Kürşad Başer

Kıdemli Yazılım Mühendisi

Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.