Dalam sebuah migrasi situs media klien yang kontennya padat, saya menulis importer sendiri untuk memindahkan export XML dari situs lama. Export-nya besar: ratusan post plus 465 attachment yang harus diunduh dan didaftarkan ke media library. Versi pertama importer saya polos: satu request, satu loop, proses semuanya sampai selesai. Saya klik tombol import, duduk manis, dan menonton spinner.
Sekitar seratus detik kemudian, spinner-nya berhenti. Bukan dengan pesan sukses, bukan juga dengan error PHP. Yang muncul adalah halaman branding Cloudflare: error 524 A Timeout Occurred. Dan bagian paling buruknya bukan timeout-nya. Bagian paling buruknya adalah import-nya mati di tengah jalan dalam keadaan tidak jelas: sebagian post sudah masuk, sebagian belum, dan saya tidak tahu batasnya di mana.
Kenapa importnya mati
Refleks pertama saya salah arah: saya naikkan max_execution_time di PHP dan set_time_limit(0) di skrip. Tidak ngefek sama sekali, dan memang tidak akan pernah ngefek. Yang membunuh proses itu bukan PHP. Yang membunuh adalah proxy di depannya.
Cloudflare punya batas keras berapa lama dia mau menunggu respons dari origin: 100 detik. Lewat dari itu, Cloudflare berhenti menunggu dan menyajikan halaman 524 ke browser. Angka itu tidak bisa dinaikkan di plan biasa, dan ini poin yang sering disalahpahami: 524 artinya origin kamu yang terlalu lambat untuk Cloudflare, bukan Cloudflare yang down. Server saya masih hidup, PHP-nya mungkin masih jalan buta di belakang, tapi koneksi ke pengunjung sudah diputus. Import 465 attachment dalam satu request jelas tidak akan pernah selesai di bawah 100 detik.
Kesimpulannya lebih umum dari kasus ini: pekerjaan web apa pun yang jalan lama di belakang proxy pasti akan dibunuh, cepat atau lambat. Bukan mungkin, pasti. Jadi desainnya yang harus berubah, bukan konfigurasi timeout-nya.
Perbaikannya: state machine ber-batch yang bisa resume
Saya rombak importer jadi state machine: setiap request hanya memproses satu batch kecil, menyimpan progres, lalu memicu request berikutnya. Batch 25 item ternyata angka yang nyaman, jauh di bawah 100 detik bahkan saat mengunduh attachment.
define( 'IMPORT_BATCH_SIZE', 25 );
function import_run_batch() {
$state = get_option( 'import_state', array( 'offset' => 0 ) );
$items = import_read_items( $state['offset'], IMPORT_BATCH_SIZE );
foreach ( $items as $item ) {
// Idempotent: skip anything a previous (crashed) run already imported.
if ( import_find_existing( $item['source_id'] ) ) {
continue;
}
$post_id = import_create_post( $item );
update_post_meta( $post_id, '_import_source_id', $item['source_id'] );
}
$state['offset'] += count( $items );
update_option( 'import_state', $state, false );
// True while there is still work left.
return count( $items ) === IMPORT_BATCH_SIZE;
}Dua keputusan di situ yang menyelamatkan saya. Pertama, progres disimpan ke option setelah setiap batch, jadi kalau ada yang mati, saya tahu persis posisi terakhir. Kedua, setiap item dicek dulu lewat meta _import_source_id sebelum dibuat. Itu yang membuat setiap batch idempotent: crash kapan pun, jalankan ulang kapan pun, tidak akan ada post dobel. Kekacauan "sebagian masuk sebagian tidak" dari run pertama pun langsung beres, karena run berikutnya tinggal melompati yang sudah ada.
Lalu bagaimana request berikutnya terpicu? Saya tidak mau bergantung pada JavaScript, dan WP-Cron di situs migrasi itu tidak bisa diandalkan. Solusi paling tua ternyata paling tahan banting: redirect meta http-equiv="refresh" kembali ke URL importer.
if ( import_run_batch() ) {
$next = admin_url( 'admin.php?page=my-importer&resume=1' );
echo '<meta http-equiv="refresh" content="1;url=' . esc_url( $next ) . '">';
echo '<p>Batch imported, continuing...</p>';
exit;
}
delete_option( 'import_state' ); // done
echo '<p>Import finished.</p>';Setiap request selesai dalam hitungan detik, jauh di bawah radar Cloudflare, lalu browser sendiri yang meminta batch berikutnya. Tanpa JS, tanpa cron, tanpa AJAX. Halaman cuma refresh terus sampai semua 465 attachment masuk.
Jebakan bonus: byte kontrol di XML sumber
Di tengah run yang sudah rapi itu, parser XML tiba-tiba throw di beberapa item. Ternyata export dari situs lama mengandung karakter kontrol mentah, byte yang tidak valid di XML, terselip di sebagian konten. Parser yang patuh spesifikasi akan langsung menolak. Perbaikannya: sanitasi byte-nya dulu sebelum parsing, buang semua karakter kontrol kecuali tab, newline, dan carriage return.
// Strip control bytes the XML parser chokes on (keep tab, LF, CR).
$xml = preg_replace( '/[^\x09\x0A\x0D\x20-\x{10FFFF}]/u', '', $xml );XML dari pihak ketiga itu jangan pernah dianggap bersih. Satu byte liar cukup untuk menjatuhkan seluruh run.
Pelajaran
Checklist saya sekarang untuk semua job panjang berbasis web: pertama, asumsikan proxy akan membunuhmu, desain untuk resumability sejak awal. Kedua, batch kecil, simpan state setelah tiap batch, dan pastikan tiap batch idempotent lewat pengecekan source-id. Ketiga, sanitasi XML pihak ketiga sebelum masuk parser. Dan keempat, kalau kamu lihat 524, berhenti menyalahkan Cloudflare: itu origin kamu yang terlalu lama menjawab. Sejak importer ini jadi state machine, angka 465 bukan lagi ancaman, cuma 19 kali refresh halaman.
