Atomic File Replacement di PHP - Biarkan Reader Melihat File Lama atau File Baru
Aplikasi PHP kecil mungkin menyimpan manifest hasil generate, file pengaturan JSON, atau cache kecil di disk. Menulis file seperti itu mudah. Pertanyaan yang lebih sulit adalah apa yang dilihat request lain saat file tersebut diganti. Apakah request itu membuka versi lama yang utuh atau versi baru yang utuh, atau justru menangkap destination setelah dikosongkan tetapi sebelum byte terakhir selesai ditulis?
Untuk update seluruh file pada filesystem Linux lokal, salah satu pola yang berguna adalah menulis temporary file di samping destination, lalu me-rename file itu untuk menggantikan file lama. Namun, deskripsi singkat tersebut menyembunyikan beberapa syarat. Atomic visibility tidak sama dengan koordinasi antar-writer, dan keduanya juga tidak sama dengan kemampuan bertahan setelah listrik padam. Artikel ini memisahkan jaminan tersebut dan menyusun helper PHP yang sengaja dibatasi di sekelilingnya.
Mengapa menulis ulang destination secara langsung itu rapuh
Membuka file yang sudah ada dengan mode yang melakukan truncation seperti wb mengurangi panjang file sebelum konten pengganti selesai ditulis. Proses write berikutnya juga bisa hanya menyelesaikan sebagian data yang diminta: PHP mendokumentasikan return value fwrite() sebagai jumlah byte yang ditulis, atau false ketika gagal. Menganggap satu pemanggilan sebagai operasi all-or-nothing berarti mengasumsikan sesuatu yang tidak dijanjikan API.
Bayangkan kita mengganti dokumen JSON valid berukuran 20 KB. Reader yang datang pada saat yang kurang tepat bisa menemukan file kosong, awalan valid yang terputus mendadak, atau dokumen utuh. Lock dapat membuat reader yang bekerja sama menunggu, tetapi semua reader kemudian harus ikut dalam protokol itu. Sering kali lebih sederhana untuk membiarkan published path tetap utuh selama versi berikutnya disusun di tempat lain.
Tiga jaminan, bukan satu
1. Atomic visibility
Spesifikasi POSIX untuk rename() menyatakan bahwa ketika destination sudah ada, directory entry-nya tetap terlihat sepanjang operasi dan merujuk ke file lama atau file baru. Di Linux, manual rename(2) menjelaskan replacement sebagai operasi atomic: proses lain yang mencari destination tidak menemukan momen ketika path itu menghilang.
Inilah sifat yang mencegah reader biasa membuka destination yang baru tersalin sebagian. Reader yang sudah membuka file lama dapat terus membaca file lama tersebut; open berikutnya akan mengarah ke file pengganti. Perilaku ini biasanya sesuai untuk snapshot hasil generate.
2. Koordinasi writer
Atomic replacement tidak menentukan writer mana yang menang jika ada dua writer berjalan bersamaan. Masing-masing dapat menyiapkan temporary file yang valid, lalu rename yang terakhir berhasil dapat menggantikan hasil sebelumnya. Tidak ada file parsial yang muncul, tetapi sebuah logical update tetap bisa hilang.
Jika writer melakukan pekerjaan read-modify-write, gunakan satu lock file yang stabil untuk mencakup seluruh rangkaian. PHP menjelaskan flock() sebagai advisory locking: mekanisme ini hanya mengoordinasikan proses yang mengikuti konvensi locking yang sama. Ini adalah kerja sama pada tingkat aplikasi, bukan penghalang terhadap program lain yang tidak mengikuti protokol.
3. Ketahanan terhadap crash
Return function yang sukses dan perpindahan nama secara atomic tidak otomatis membuktikan bahwa setiap lapisan storage telah membuat perubahan itu durable. PHP 8.1 memperkenalkan fsync(), yang meminta operating system menyinkronkan data dan metadata file ke storage. Menyinkronkan temporary file sebelum rename memperkuat urutan ini, tetapi rename juga mengubah sebuah directory entry. Kebutuhan crash consistency yang ketat dapat memerlukan sinkronisasi containing directory melalui fasilitas khusus platform, sekaligus mempertimbangkan filesystem, mount option, drive cache, dan hardware.
Perbedaan ini penting. “Reader tidak melihat setengah file” merupakan janji yang lebih sempit dan lebih dapat dipertanggungjawabkan daripada “file terbaru bertahan dari setiap crash.”
Implementasi PHP dengan cakupan terbatas
Helper berikut menargetkan regular local filesystem pada POSIX/Linux. Helper ini membuat temporary file di destination directory, memastikan tempnam() tidak melakukan fallback ke lokasi lain, menulis setiap byte, melakukan sinkronisasi file secara opsional, menutup file, lalu mengganti destination. Setiap kegagalan sebelum rename membiarkan destination lama tetap utuh dan berusaha menghapus temporary file.
<?php
function atomicReplace(string $path, string $data, bool $syncFile = false): void
{
$directory = dirname($path);
$realDirectory = realpath($directory);
if ($realDirectory === false || !is_dir($realDirectory) || !is_writable($realDirectory)) {
throw new RuntimeException('Destination directory is not writable');
}
$temporary = tempnam($realDirectory, '.replace-');
if ($temporary === false) {
throw new RuntimeException('Could not create a temporary file');
}
// tempnam() may fall back to the system temp directory.
if (realpath(dirname($temporary)) !== $realDirectory) {
@unlink($temporary);
throw new RuntimeException('Temporary file was created on another path');
}
$handle = null;
try {
$handle = fopen($temporary, 'wb');
if ($handle === false) {
throw new RuntimeException('Could not open the temporary file');
}
$length = strlen($data);
$offset = 0;
while ($offset < $length) {
$written = fwrite($handle, substr($data, $offset));
if ($written === false || $written === 0) {
throw new RuntimeException('Could not write the complete file');
}
$offset += $written;
}
if (!fflush($handle)) {
throw new RuntimeException('Could not flush the temporary file');
}
if ($syncFile) {
if (!function_exists('fsync') || !fsync($handle)) {
throw new RuntimeException('Could not synchronize the temporary file');
}
}
if (!fclose($handle)) {
throw new RuntimeException('Could not close the temporary file');
}
$handle = null;
if (!rename($temporary, $path)) {
throw new RuntimeException('Could not replace the destination');
}
} finally {
if (is_resource($handle)) {
fclose($handle);
}
if (is_file($temporary)) {
@unlink($temporary);
}
}
}
Pemeriksaan lokasi bukan sekadar hiasan. Dokumentasi resmi tempnam() menyebutkan bahwa PHP dapat membuat file di system temporary directory ketika directory yang diminta tidak ada atau tidak writable. POSIX mengizinkan cross-filesystem rename gagal dengan EXDEV, dan Linux juga menolak rename yang melintasi mount point. Membuat temporary file di samping destination membuat operasi normal tetap berada pada satu mounted filesystem.
Helper ini juga memeriksa kondisi tanpa progress dalam write loop. Loop yang terus mengulang saat fwrite() mengembalikan nol dapat mengubah kegagalan I/O menjadi request yang macet. Function ini memilih melempar exception, lalu block finally membersihkan apa yang dapat dibersihkan.
Manual rename() PHP menyatakan bahwa destination file yang sudah ada akan ditimpa dan keberhasilan dilaporkan sebagai true. Code tetap memeriksa hasil tersebut karena permission, filesystem penuh, read-only mount, atau I/O error dapat membuat operasi gagal. Menyembunyikan kegagalan itu akan mengubah file lama yang masih dapat dipulihkan menjadi update yang secara keliru dilaporkan sukses.
Kapan lock file perlu mengelilingi replacement
Untuk snapshot independen ketika “writer terakhir yang selesai menang” dapat diterima, atomic replacement mungkin sudah cukup. Untuk counter, queue, atau operasi read-modify-write apa pun, itu belum cukup. Lock harus dimulai sebelum membaca state saat ini dan tetap dipegang sampai rename berhasil.
$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Could not acquire the update lock');
}
try {
$current = is_file($path) ? file_get_contents($path) : '{}';
if ($current === false) {
throw new RuntimeException('Could not read the current file');
}
$next = buildNextDocument($current);
atomicReplace($path, $next, true);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
Path .lock yang terpisah dan stabil itu penting. Mengunci inode destination lalu mengganti destination tersebut akan mengubah inode yang ditunjuk oleh path. Writer lain kemudian dapat mengunci file baru saat proses pertama masih memegang lock pada file lama. Dedicated lock file memberi semua writer yang bekerja sama satu titik pertemuan yang sama.
Batasan yang perlu tetap terlihat
- Semantik platform berbeda. Contoh ini sengaja menargetkan POSIX/Linux. PHP mendokumentasikan batasan khusus Windows untuk mengganti destination yang sudah ada; jangan mengklaim perilaku yang identik tanpa desain dan pengujian Windows.
- Network filesystem memerlukan analisis tersendiri. Manual Linux mencatat ambiguitas NFS: server bisa menyelesaikan rename lalu gagal sebelum client menerima hasilnya. Percobaan ulang dapat melaporkan kegagalan meski nama telah berubah. Helper ini tidak menyelesaikan semantik distributed filesystem.
- Permission dapat berubah.
tempnam()membuat file dengan mode0600. Setelah replacement, mode itulah yang dimiliki destination. Jika user atau group lain perlu membaca file tersebut, atur dan verifikasi ownership serta mode yang dimaksud sebelum memublikasikannya, dengan kebijakan yang dikontrol secara hati-hati. - Path tetap membutuhkan trust boundary. Helper mengasumsikan destination path dan parent directory dikontrol oleh aplikasi. Ini bukan pertahanan terhadap attacker yang dapat mengubah directory entry atau memberikan arbitrary path.
- Atomic bukan berarti tervalidasi. Validasi atau encode dokumen lengkap sebelum memanggil helper. Atomic replacement tetap dapat memublikasikan JSON tidak valid dengan sempurna.
- Volume write yang tinggi mungkin lebih cocok memakai database. Lock, temporary file, operasi sync, recovery rule, dan banyak record merupakan hal-hal yang sudah diatur oleh database. Filesystem snapshot berguna antara lain karena cakupannya kecil.
Cara menguji jalur kegagalan
Happy-path test sebaiknya memverifikasi byte hasil secara persis dan memastikan tidak ada file .replace-* yang tersisa. Test yang lebih berguna dapat membuat directory tidak writable, memakai parent yang tidak ada, memenuhi test filesystem kecil, menjalankan beberapa writer yang bekerja sama, dan sengaja melempar exception sebelum rename. Setelah setiap percobaan gagal, periksa bahwa destination lama masih utuh.
Concurrency test perlu membedakan dua pertanyaan. Pertama, apakah ada reader yang melihat konten malformed atau parsial? Kedua, apakah aplikasi mempertahankan setiap logical update? Atomic rename menjawab yang pertama; stable writer lock atau transactional data store menjawab yang kedua. Pengujian crash durability adalah kategori lain lagi dan sebaiknya mengikuti filesystem serta storage stack yang benar-benar digunakan, bukan sekadar simulasi laptop development.
Kesimpulan
Replacement seluruh file yang aman bukan soal menemukan write flag ajaib, melainkan menyebutkan jaminan yang benar-benar dibutuhkan. Tulis unique temporary file di destination directory, periksa setiap write, tutup file, lalu rename untuk menggantikan published path agar ordinary reader tidak melihat konten parsial. Tambahkan stable advisory lock ketika writer harus berjalan secara serial. Tambahkan sinkronisasi dan pekerjaan khusus platform hanya ketika kebutuhan durability memang membenarkannya.
Pola ini cocok untuk snapshot sederhana yang dapat diganti. Ketika file berkembang menjadi multi-writer data model, queue, atau sumber critical durable state, pertanyaan yang lebih berguna mungkin bukan lagi cara menulis file tersebut, melainkan apakah data itu seharusnya tetap menjadi file.
