Wanzul Blog

Nota, pendapat, dan pengajaran yang aku bayar sendiri.

My Self-Hosted Stack Hit A "Corrupted Migrations" Wall

Stack Self-Hosted Aku Langgar Dinding “Corrupted Migrations”

Kalau kau pernah jalankan aplikasi self-hosted atas AWS EC2 dengan Docker Compose, kau tahu jalan upgrade tu tempat kau selalu kena gigit. Malam tadi container aku enggan start, dan errornya jenis yang buat kau nak campak laptop.

Log tu kata:

corrupted migrations: expected previously executed migration
20260205T214213-add-settings-to-spaces to be at index 25
but 20250831T191600-add-group-sync-to-auth-providers was found in its place.

New migrations must always have a name that comes alphabetically after the
last executed migration.

Nampak macam arahan yang jelas, kan? Masukkan migration yang hilang, susun ikut abjad, selesai. Kecuali — itu bukan masalah sebenar. Jauh sama sekali. Aku cerita apa yang betul-betul berlaku.

Simptom

Container tu terus crash masa boot. Error tu dilempar oleh migrator Kysely dalam onApplicationBootstrap. Postgres boleh dicapai. Redis boleh dicapai. Fail migration ada dalam imej. Satu-satunya benda yang “salah” ialah semakan susunan tu.

Andaian pertama yang salah

Aku andaikan — macam mana-mana developer yang munasabah — bahawa “alphabetically after the last executed migration” bermaksud susunan abjad ikut nama fail. Aku tambah baris yang hilang ke dalam kysely_migration, restart, dan dapat error yang sama.

Cuba lagi. Index lain. Error sama.

Cuba bersihkan “duplicate”. Error sama.

Aku pun tarik imej baharu, salin keluar folder migrations, dan sahkan ia sama bait-demi-bait dengan repo aku. 46 fail migration atas disk. 46 baris dalam database. Semua sejajar bila ls | sort. Masih error yang sama.

Puncanya

Selepas kira-kira dua jam, barulah aku pergi ke sumber. migrator.js dalam Kysely 0.28.17 ada ini di bahagian #getExecutedMigrations:

return (executedMigrations
  .sort((a, b) => {
    if (a.timestamp === b.timestamp) {
      return nameComparator(a.name, b.name);
    }
    return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
  })
  .map((it) => it.name));

Baca betul-betul. Senarai yang sudah dilaksanakan disusun ikut timestamp (di-parse sebagai Date), bukan ikut nama. Nama cuma pemecah seri.

Sementara itu, senarai dari fail sistem memang disusun ikut nama. Jadi Kysely berjalan atas dua senarai, dua-dua panjang 46, disusun dengan kunci berbeza, dan mengeluh bila ia tak sejajar pada index yang sama.

Dua susunan tu hanya bersetuju kalau timestamp setiap migration naik secara monotonik ikut susunan namanya. Untuk nama fail yang ikut konvensyen YYYYMMDDTHHMMSS-name.ts, awalan tarikh tu kebetulan sudah dalam susunan abjad — jadi selagi timestamp kau cermin awalan nama fail, dua susunan tu sejajar.

Kenapa insert pertama aku gagal

Masa aku tampal jadual kysely_migration kali pertama, aku guna NOW() untuk timestamp — sebab, ya, “bila migration ni berjalan” itu benda yang munasabah untuk direkodkan.

Timestamp tu mendarat pada Jun 2026, selepas hampir semua baris lain. Jadi Kysely susun baris aku ke kedudukan lain dalam susunan timestamp, sementara susunan fail sistem letak ia pada index yang lain pula. Kysely pun mengeluh tentang kedudukan itu.

Mesej error yang kau nampak — index tertentu, nama tertentu — cuma Kysely beritahu “baris yang aku jangka pada index ni tiada di sini.” Itu simptom ketidaksejajaran timestamp dengan nama, bukan puncanya.

Pembetulannya

Tipu dayanya ialah terbitkan timestamp setiap migration dari awalan YYYYMMDDTHHMMSS pada namanya sendiri. Format sebagai YYYY-MM-DDTHH:MM:SS.000Z. Dengan itu:

  • Setiap timestamp unik (setiap nama ada awalan unik), jadi pemecah seri tak pernah dipanggil.
  • Susunan timestamp sama dengan susunan nama fail, jadi dua senarai tu sejajar sepenuhnya.

Skrip yang aku jalankan (dari dalam container yang sedang berjalan, supaya env var DATABASE_URL jadi sumber kebenaran) lebih kurang macam ni:

const rows = [
  ['20240324T085400-uuid_v7_fn', '2024-03-24T08:54:00.000Z'],
  ['20240324T085500-workspaces', '2024-03-24T08:55:00.000Z'],
  // ... 44 baris lagi
  ['20260509T121236-labels', '2026-05-09T12:12:36.000Z'],
];

for (const [name, ts] of rows) {
  await sql`
    INSERT INTO kysely_migration (name, timestamp)
    VALUES (${name}, ${ts})
    ON CONFLICT (name) DO UPDATE SET timestamp = EXCLUDED.timestamp
  `;
}

Aku jalankan dengan ON CONFLICT DO UPDATE supaya selamat diulang, dan guna DATABASE_URL dari container aplikasi yang sedang berjalan (melalui docker exec <container> node -e "...") supaya aku 100% kena database yang betul. Skrip tu juga dump hasil dalam susunan yang sama macam Kysely susun, jadi aku boleh nampak dengan mata sebelum restart.

Selepas itu:

sudo docker compose restart <app-service>
sudo docker compose logs --tail 30 <app-service>

-> DatabaseMigrationService: No pending database migrations

Aplikasi boot. Selesai.

Yang paling penting

Dalam Kysely 0.28.17, kolum timestamp itu kunci susunan utama, bukan metadata. Bila kau insert manual ke kysely_migration untuk baiki keadaan corrupted, nilai timestamp mesti diterbitkan dari awalan YYYYMMDDTHHMMSS pada nama itu sendiri — bukan dari NOW() dan bukan dari run_at asal. Kalau tidak, dua susunan tu bertengkar dan kau dapat error “corrupted migrations” yang mengelirukan tu, yang menunjuk ke index yang salah.

Mesej error tu kata “alphabetically”. Ia menipu. Ia disusun secara kronologi, dengan nama sebagai pemecah seri.

Tip praktikal

  • Selalu jalankan SQL diagnostik dari dalam container yang sedang berjalan, guna DATABASE_URL dia sendiri. Jangan percaya .env lokal kalau kau ada compose environment: override — dua-dua selalu tak sependapat.
  • Kalau index dalam error tu berubah antara restart, itu petunjuk ada benda lain sedang mengubah jadual tu. Dalam kes aku, ia timestamp placeholder yang basi; dalam kes lain, ia boleh jadi instance aplikasi kedua yang tuju database sama, cron job, atau gelung restore backup.
  • Jadual kysely_migration guna name sebagai PRIMARY KEY, jadi bentuk ON CONFLICT (name) DO UPDATE tu wajib untuk idempotensi. Tanpa itu, larian semula akan gagal dengan unique-constraint violation, atau — kalau kau entah macam mana buang PK tu — cipta baris berganda yang geser setiap index berikutnya satu.
  • Nama dalam kysely_migration.name ialah nama fail tanpa sambungan .ts. Kalau codebase kau ada nama fail dengan titik tambahan (contoh 20250729T213756-add-unaccent-..ts), buang .ts sahaja; titik dalam tu kekal dalam kolum nama. Senang tersilap kalau kau bina array tu dengan tangan.

Berbaloi ke dua jam tu?

Ya. Sekarang aku ada stack prod yang berjalan, kefahaman yang jauh lebih dalam tentang dalaman Kysely daripada yang aku minta, dan artikel ini untuk selamatkan orang seterusnya dari kesusahan yang sama. Kalau kau kena error ni, pembetulannya ialah tipu daya timestamp-dari-nama di atas.

Pengajarannya umum: bila mesej error sesebuah library dan algoritma susunan sebenar tak sependapat, pergi baca sumber.

Tahu tak? Issue tracker Kysely ada issue #843 tentang tingkah laku susunan ni. Maintainer dah tahu. Mereka kekalkan ia begini dengan sengaja untuk kestabilan susunan merentas larian migrator. Itu pun bagus, sampai ia gigit kau.

Stack: NestJS 11, Kysely 0.28.17, Postgres 18, Docker Compose atas EC2 Ubuntu 24.04. Jumlah downtime: ~2 jam. Pening kepala: RM 0 (alahan aspirin).

Wan Zulkarnain Avatar

Written by 


Comments

Leave a Reply