Sebuah platform booking yang saya pegang untuk situs klien punya plugin custom yang otomatis membuat halaman-halaman wajib saat aktivasi. Suatu hari saya buka Pages di admin dan angkanya bikin saya berhenti sejenak: 137 halaman. Harusnya sekitar 17. Sisanya, hampir 120 baris, adalah duplikat: add-property-2, add-property-3, add-property-4, terus sampai puluhan, dan pola yang sama untuk bookings-2, bookings-3, dan seterusnya. Daftar halaman kelihatan seperti mesin fotokopi yang macet dalam posisi menyala.
Yang bikin penasaran, bukan semua halaman menggandakan diri. Hanya dua yang membelah: add-property dan bookings. Keduanya kebetulan child page dari owner-dashboard, dashboard tempat pemilik properti mengelola listing mereka. Semua halaman top-level, home, kontak, terms, tetap satu biji. Hanya dua child nested itu yang beranak-pinak.
Jalan buntu yang saya coba dulu
Refleks pertama saya salah sasaran. Saya kira ada yang iseng klik tombol, atau ada cron nakal yang loop. Jadi saya bersihkan manual: pilih semua add-property-N dan bookings-N, buang ke trash, kosongkan trash. Pages kembali ke 17. Lega sebentar.
Lalu deploy berikutnya keluar, saya bump versi plugin, dan duplikatnya balik lagi. Bukan pelan-pelan, tapi langsung sepasang baru muncul persis setelah version bump. Di titik itu saya berhenti menyalahkan orang. Ketika sebuah bug muncul ulang tepat pada saat yang bisa diprediksi, itu bukan kelakuan manusia, itu deterministik. Dan satu-satunya hal yang jalan pada version bump adalah rutinitas bootstrap plugin saya sendiri.
Akar masalahnya
Plugin ini punya bootstrap yang memastikan semua halaman wajib ada, digerbangi oleh sebuah opsi PCP_VERSION. Tiap kali konstanta versi naik, bootstrap jalan sekali dan memanggil pcp_ensure_page() untuk tiap halaman yang dibutuhkan. Fungsi itu seharusnya idempoten: kalau halaman sudah ada, jangan bikin lagi. Cara dia mengecek keberadaan seperti ini:
$existing = get_page_by_path( $slug ); // $slug = 'add-property'
if ( ! $existing ) {
wp_insert_post( /* ... halaman baru ... */ );
}Untuk halaman top-level, ini benar. get_page_by_path('kontak') menemukan halaman kontak, guard-nya kena, tidak ada insert. Masalahnya muncul begitu halaman itu punya parent.
add-property dan bookings bukan halaman top-level. Mereka child dari owner-dashboard. Di WordPress, path sebuah child page bukan slug telanjangnya, tapi path penuh lewat parent: owner-dashboard/add-property, bukan add-property. Dan get_page_by_path() mencocokkan berdasarkan path penuh itu. Jadi:
get_page_by_path( 'add-property' ); // null, walau halamannya ADA
get_page_by_path( 'owner-dashboard/add-property' ); // ketemuPemanggilan dengan slug telanjang selalu balik null. Guard-nya menyimpulkan halaman belum ada, lalu wp_insert_post() bikin satu lagi. Karena slug add-property sudah dipakai di bawah parent yang sama, WordPress tidak menolak, dia diam-diam menambahkan suffix angka: add-property-2, lalu add-property-3 di bump berikutnya, dan seterusnya. Tiap version bump menambah satu lapis duplikat untuk tiap child nested. Itu sebabnya cuma dua halaman itu yang membengkak, dan cuma pada version bump.
Yang menipu: tidak ada error sama sekali. get_page_by_path() balik null itu perilaku yang benar-benar valid untuk path yang tidak cocok, dan wp_insert_post() sukses tiap kali. Dari sisi PHP tidak ada yang gagal. Bug ini hidup di celah antara "path yang saya cek" dan "path yang sebenarnya dipakai halaman".
Perbaikannya
Ada dua bagian, dan urutannya penting. Yang pertama menghentikan pendarahan, yang kedua membersihkan luka lama.
Pertama, cek keberadaan lewat path penuh, bukan slug telanjang. Saya beri pcp_ensure_page() parameter parent dan bangun path yang benar sebelum lookup:
function pcp_ensure_page( $slug, $title, $parent_slug = '' ) {
$path = $parent_slug ? $parent_slug . '/' . $slug : $slug;
$existing = get_page_by_path( $path );
if ( $existing ) {
return $existing->ID; // idempoten sekarang benar untuk child nested
}
return wp_insert_post( /* ... */ );
}Dengan $parent_slug = 'owner-dashboard', lookup-nya jadi owner-dashboard/add-property dan cocok dengan halaman yang sudah ada. Guard-nya berfungsi, insert berhenti. Version bump tidak lagi menghasilkan duplikat.
Kedua, bersihkan ratusan dupe yang sudah menumpuk. Saya tambahkan pcp_cleanup_duplicate_child_pages($max = 50) ke dalam bootstrap yang digerbangi versi tadi. Fungsi ini menghapus duplikat yang menumpuk, tapi diberi lingkup yang ketat: hanya menyasar child yang persis bernama add-property, add-property-2, add-property-3, dan seterusnya di bawah parent yang memang wajib, tidak menyentuh halaman lain. Ada beberapa penjaga yang saya anggap wajib:
- Selalu simpan halaman yang di-resolve oleh pretty URL. Apa pun yang terjadi, satu halaman kanonik untuk tiap child harus selamat, jadi link yang sudah beredar tidak patah.
- Batasi 50 penghapusan per run. Dengan backlog 120-an, satu request bisa timeout kalau semua dihapus sekaligus.
- Buat fungsinya bisa lanjut sendiri: opsi versi sengaja dibiarkan kosong sampai jumlah dupe 0. Jadi run berikutnya melanjutkan pekerjaan sampai bersih, tanpa perlu campur tangan manual.
Setelah semuanya jalan, jumlah halaman produksi turun dari 137 ke 17, dan tetap di 17 melewati beberapa version bump berikutnya.
Pelajaran
- Path child page di WordPress adalah
parent-slug/child-slug, bukan slug telanjang.get_page_by_path()mencocokkan path penuh. - Kalau kamu mengecek keberadaan halaman nested dengan slug telanjang, hasilnya selalu
null, guard idempotenmu bocor, dan insert jalan terus. - WordPress tidak menolak slug duplikat di bawah parent yang sama, dia menambah suffix
-2,-3. Loop yang tak terlihat bisa menumpuk ratusan halaman tanpa satu pun error. - Bug yang muncul ulang tepat pada momen yang bisa diprediksi, seperti version bump, itu deterministik. Curigai kodemu sendiri, bukan orang lain.
- Kalau menulis job cleanup massal, beri lingkup ketat, selalu simpan halaman kanonik, batasi per run, dan buat bisa lanjut sendiri supaya backlog besar tidak timeout.
