Ada satu berkas HTML yang saya simpan sore itu, dan isinya dua hal yang seharusnya tidak bisa berdampingan dalam satu respons. Di satu bagian ada markup dari fitur yang baru saya tulis beberapa menit sebelumnya, masih hangat, belum sempat saya rapikan. Di bagian lain ada elemen <video> yang menunjuk berkas mp4 yang sudah saya hapus dari disk berjam-jam yang lalu.
Artinya proses PHP yang merender halaman itu membaca kode versi paling baru, tapi menjawab pertanyaan "apakah berkas ini masih ada" dengan jawaban paling basi. Sampai titik itu saya masih yakin masalahnya ada di lapisan cache kode. Satu respons itu yang membuktikan saya salah, dan sekaligus menunjukkan ke mana harus mencari.
Gejalanya muncul-hilang antar request
Halaman yang saya kerjakan punya latar video. Berkas mp4-nya sudah tidak dipakai lagi, jadi saya hapus, dan markup yang memanggilnya memang di-gate pemeriksaan keberadaan berkas. Secara logika elemen itu seharusnya berhenti dirender pada detik berkasnya lenyap.
Kenyataannya dia masih muncul, dan tiap kali muncul dia menyeret satu request 404 ke berkas yang sudah tidak ada. Yang merepotkan bukan 404-nya, melainkan ketidakkonsistenannya. Saya reload, elemennya ada. Reload lagi, hilang. Saya tarik HTML-nya dengan curl, sering kali bersih tanpa jejak elemen itu sama sekali.
Gejala yang beda-beda antar request seperti ini punya daya rusak khusus buat proses debugging, karena tiap tindakan yang saya ambil terasa berhasil separuh waktu. Saya membersihkan sesuatu, reload, elemennya hilang, dan saya sempat percaya sudah selesai. Reload berikutnya membatalkan kesimpulan itu.
Tuduhan pertama saya jatuh ke OPcache
Saya menghabiskan waktu cukup lama menyalahkan OPcache. Alasannya masuk akal di permukaan: kode PHP yang tersimpan sebagai bytecode di memori memang bisa tertinggal dari berkas di disk, dan gejalanya sama-sama "yang jalan bukan yang saya tulis". Saya bersihkan, saya restart, dan sesekali halamannya memang bersih setelah itu. Kebersihan yang sesekali itu justru memperpanjang kesalahan, karena setiap kali dia muncul saya membacanya sebagai konfirmasi.
Yang tidak pernah cocok dengan teori OPcache adalah pola waktunya. OPcache basi itu seragam. Selama bytecode lama masih dipegang, semua request mendapat kode lama, bukan dua dari sepuluh. Kalau gejalanya intermiten sementara teorinya deterministik, biasanya bukan gejalanya yang salah.
Browser bukan alat ukur yang jujur di sini
Ada satu hal lagi yang selama itu diam-diam menyesatkan saya, dan baru ketahuan setelah semuanya selesai. Di browser gejalanya terasa jauh lebih konsisten daripada di curl. Saya sempat menganggap curl "tidak melihat" masalahnya, padahal yang terjadi sebaliknya.
Browser membuka koneksi persisten dan cenderung menempel ke proses yang sama untuk beberapa request berturut-turut. Kalau proses itu kebetulan yang membawa state basi, semua yang saya lihat di tab itu akan konsisten rusak. Kalau kebetulan proses yang bersih, konsisten benar. curl yang membuka koneksi baru setiap kali justru menyebar request ke banyak proses, dan hasilnya jadi acak.
Jadi perbedaan antara "konsisten di browser" dan "acak di curl" bukan gangguan yang harus diabaikan. Itu sudah setengah jawabannya, cuma waktu itu saya membacanya terbalik.
Sepuluh request, dua yang basi
Begitu saya berhenti menyimpulkan dari satu reload dan mulai memperlakukan gejalanya sebagai sesuatu yang perlu dihitung, semuanya jadi cepat. Saya sampling sepuluh request lalu hitung berapa yang membawa elemen hantunya:
for i in $(seq 1 10); do
curl -s http://situs-lokal.test/ | grep -c '<video'
doneHasilnya delapan baris 0 dan dua baris 1. Dua dari sepuluh, dan angkanya bertahan waktu saya ulang.
Rasio yang stabil di angka pecahan seperti itu tidak cocok dengan cache mana pun yang dipegang satu tempat untuk semua orang. Cache kode dan cache halaman punya satu salinan yang dipakai bersama, jadi hasilnya seragam sampai salinan itu dibuang. Rasio yang menetap di sekitar dua per sepuluh menunjuk ke bentuk masalah yang lain: ada beberapa proses PHP yang melayani request bergantian, dan sebagian membawa isi kepala yang berbeda dari sisanya.
Digabung dengan respons yang berisi markup terbaru sekaligus elemen hantunya, arahnya tinggal satu. Kodenya segar, jadi bukan bytecode-nya yang basi. Yang basi adalah hasil pemeriksaan berkasnya.
Stat cache PHP hidup di proses, bukan di request
PHP menyimpan hasil pemeriksaan berkas seperti file_exists() supaya tidak perlu menanyakan hal yang sama ke sistem berkas berulang kali. Di setup yang saya pakai waktu itu, sebuah runtime WordPress lokal yang workernya berumur panjang, cache itu hidup ikut umur proses, bukan ikut umur satu request.
Konsekuensinya persis yang saya lihat. Beberapa worker sudah lebih dulu memanggil file_exists() pada path mp4 itu ketika berkasnya masih ada, lalu menyimpan jawaban "ada". Berkasnya saya hapus, tapi worker-worker itu tidak pernah menanyakannya lagi. Mereka menjawab dari ingatan, dan ingatan mereka sudah berjam-jam umurnya. Worker yang lahir setelah penghapusan menjawab dengan benar. Request yang mendarat di kelompok pertama merender elemen hantunya, request yang mendarat di kelompok kedua tidak, dan pembagiannya kira-kira dua berbanding delapan.
Ini juga yang membuat penyamarannya efektif. Gejalanya terlihat seperti OPcache, seperti page cache, bahkan sempat terlihat seperti race condition, karena ketiganya sama-sama menghasilkan "hasil yang berbeda untuk request yang sama". Bedanya cuma di lapisan mana state-nya menumpuk.
Perbaikannya satu panggilan sebelum pemeriksaan
Markupnya sendiri tidak salah. Yang salah adalah asumsi bahwa file_exists() menjawab pertanyaan tentang keadaan disk sekarang. Untuk berkas yang bisa muncul atau hilang saat runtime, jawabannya perlu dipaksa segar:
$video = $dir . '/hero-loop.mp4';
clearstatcache( true, $video );
if ( file_exists( $video ) ) {
// render elemen <video>
}clearstatcache( true, $path ) membuang entri untuk satu path saja, bukan seluruh cache, jadi ongkosnya kecil dan terukur. Untuk satu markup yang di-gate satu berkas, itu berarti tambahan satu pemanggilan stat per request. Harga segitu jelas lebih murah daripada elemen hantu yang menghasilkan 404 di sebagian pengunjung dan tidak bisa direproduksi dengan reload biasa.
Yang perlu ditandai adalah kapan pola ini pantas dipakai. Bukan untuk semua file_exists(), karena sebagian besar path yang diperiksa memang statis sepanjang umur proses. Yang butuh pembersihan adalah path yang isinya bisa berubah saat proses masih hidup, misalnya berkas yang ditulis atau dihapus oleh proses lain, atau aset yang dibuat saat runtime.
Yang membuat kerusakannya berhenti di satu request
Bagian ini yang paling ingin saya bawa pulang, dan sebenarnya bukan tentang stat cache.
Elemen video itu dirancang tidak terlihat sampai playback benar-benar mulai. Ada satu class yang baru dipasang setelah event playback nyata terjadi, dan sebelum itu lapisannya diam saja di belakang. Karena mp4-nya 404, playback tidak pernah mulai, jadi class-nya tidak pernah terpasang, jadi tidak ada yang berubah di layar.
Itu sebabnya bug yang berumur berjam-jam ini biayanya cuma satu request 404 yang tak terlihat, bukan kotak hitam menganga di tengah halaman selama satu hari kerja. Lapisan yang cuma muncul setelah bukti nyata bahwa dia berhasil dimuat memaafkan banyak sekali kesalahan di belakangnya, termasuk kesalahan yang tidak saya duga akan ada.
Sisa pelajarannya soal cara mengukur. Kalau sebuah bug memberi hasil berbeda untuk request yang sama, hampir selalu ada state yang hidup lebih lama daripada satu request, dan PHP punya lebih dari satu lapis tempat state semacam itu menumpuk. OPcache cuma yang paling terkenal. Cara tercepat memisahkannya bukan menebak lapisannya, melainkan mengubah gejalanya jadi angka: kirim sepuluh request, hitung berapa yang kena. Seragam berarti satu salinan bersama, sedangkan pecahan yang menetap berarti beberapa proses dengan ingatan yang berbeda-beda.