Sebuah file konfigurasi yang berfungsi lima detik lalu kini menjadi fragmen JSON rusak berukuran 347-byte. Alat CLI Anda tidak akan berjalan. Pengguna, yang hanya menekan Ctrl-C karena pembaruan memakan waktu lebih lama dari yang diperkirakan, kini menatap stack trace error yang tidak mereka minta. Dua kilobyte konfigurasi valid yang ada sebelum penulisan telah hilang, digantikan oleh padanan digital dari struk yang tercetak setengah jalan.
Ini terjadi karena writeFile tidak bersifat atomik. Ia membuka jalur yang ada, memotongnya (truncate), mengalirkan data dari Node ke dalam page cache kernel, dan akhirnya menutup file descriptor. Saat pemotongan terjadi, konten lama sudah hilang. Segala sesuatu di antara pemotongan tersebut dan close terakhir adalah jendela kerentanan. Sebuah SIGINT, pemadaman listrik, atau penutupan layar laptop secara tiba-tiba selama jendela tersebut akan membuat filesystem menyimpan data yang berantakan dan terpotong. Bahkan jika Node melaporkan Promise telah resolved, sistem operasi mungkin masih melakukan buffering penulisan di memori. Metode kemudahan (convenience methods) menyembunyikan celah tersebut, tetapi tidak menghilangkannya.
Solusinya bukan menulis di tempat yang sama. Solusinya adalah memisahkan tindakan penulisan dari tindakan publikasi.
Tulis ke file pendamping, lalu tukar
Pola yang andal ini memiliki lima langkah. Tidak ada yang rumit, tetapi secara bersama-sama mereka memperkecil jendela kegagalan dari seluruh proses penulisan streaming menjadi satu operasi metadata filesystem saja.
Pertama, serialisasikan seluruh payload di dalam memori. Lakukan ini sebelum Anda membuat file sementara apa pun. Jika JSON.stringify melempar error karena seseorang memasukkan objek sirkular, Anda ingin pengecualian tersebut muncul (bubble up) sebelum Anda menyentuh disk.
Kedua, tulis data yang telah diserialisasi ke file sementara yang terletak di direktori yang sama dengan target. Gunakan nama acak agar dua proses yang berjalan bersamaan tidak bertabrakan. Menyimpan file temp di direktori yang sama itu penting karena rename hanya bersifat atomik dalam satu filesystem tunggal. Jika file temp Anda berada di partisi yang berbeda, sistem operasi akan beralih ke urutan salin-dan-hapus (copy-and-delete), yang memperkenalkan mode kegagalan tersendiri dan tidak lagi bersifat atomik.
Ketiga, minta kernel untuk melakukan flush pada file sementara tersebut ke penyimpanan fisik. fsync milik Node, yang di sini diekspos sebagai metode sync pada filehandle, akan memblokir proses hingga buffer benar-benar tertulis ke perangkat keras. Ini memang lambat, tetapi penulisan konfigurasi cukup jarang terjadi sehingga durabilitasnya sepadan dengan waktu beberapa milidetik tersebut.
Keempat, ganti nama (rename) file sementara tersebut ke jalur asli. Baik pada sistem POSIX maupun Windows, ini adalah titik commit. Pembaca yang membuka jalur asli akan melihat file lama yang lengkap atau file baru yang lengkap. Tidak ada momen di mana pembaca dapat membuka jalur tersebut dan melihat buffer yang baru tertulis setengah jalan.
Kelima, sinkronkan (sync) direktori induk. Ini menangani kasus tepi (edge case) yang halus. Operasi rename memperbarui entri direktori, tetapi metadata direktori itu sendiri mungkin masih berada di page cache kernel. Kehilangan daya secara tiba-tiba setelah rename yang berhasil terkadang dapat membuat filesystem berada dalam kondisi di mana referensi inode baru tidak pernah tercatat secara durabel. Melakukan sync pada direktori memaksa pembaruan metadata tersebut ke disk dan menyegel transaksi.
Implementasi Node.js yang konkret
Berikut adalah tampilan pola tersebut dalam praktiknya hanya dengan menggunakan pustaka standar Node.js:
import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export async function writeJsonAtomic(path, value) {
const directory = dirname(path);
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(value, null, 2)}\n`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
const directoryHandle = await open(directory, "r");
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
Beberapa detail dalam kode ini patut diperhatikan.
Flag wx berarti "tulis, tetapi gagal jika file sudah ada." Ini melindungi dari tabrakan UUID atau file temp yang ditinggalkan oleh proses sebelumnya yang crash. Jika seseorang telah menaruh file berbahaya di tempat yang seharusnya menjadi file temp Anda, Anda akan segera mengetahuinya alih-alih menimpa apa pun yang ada di sana.
Mask izin 0o600 membuat file temp hanya dengan izin baca-pemilik (owner-read) dan tulis-pemilik (owner-write). File konfigurasi sering kali berisi rahasia, token API, atau URL repositori pribadi. Tidak ada alasan untuk membiarkan pengguna lain di sistem mengintip file sementara saat sedang disiapkan.
Perhatikan panggilan sync yang terpisah pada file dan kemudian pada direktori. Banyak pengembang melewatkan sinkronisasi direktori karena terasa redundan. Padahal tidak. Ext4, APFS, dan NTFS semuanya menangani pembaruan direktori secara berbeda, tetapi mereka memiliki kebiasaan umum dalam melakukan batching penulisan metadata demi performa. Jika Anda peduli tentang bertahan dari kehilangan daya, sinkronisasi direktori adalah segel terakhirnya.
Pembersihan di dalam blok catch sengaja dilakukan secara defensif. Jika terjadi kesalahan setelah handle file dibuka, kode akan mencoba menutup handle tersebut dan menghapus file sementara, sambil mengabaikan kesalahan sekunder apa pun agar pengecualian (exception) asli dapat terpropagasi dengan bersih. Anda tentu tidak ingin kesalahan izin (permission error) saat pembersihan menutupi bug sebenarnya yang menyebabkan kegagalan tersebut.
Di mana pola ini berhenti membantu
Penggantian file secara atomik mencegah torn writes. Hal ini tidak mencegah lost updates. Jika dua instansi CLI Anda membaca konfigurasi yang sama secara bersamaan, keduanya melakukan pengeditan di memori, keduanya menulis file sementara baru, dan keduanya mengeksekusi penggantian nama (rename), maka penggantian nama kedua yang akan menang. Proses pertama tidak melihat perubahan yang dilakukan oleh proses kedua. Tergantung pada aplikasi Anda, ini bisa berarti seorang pengguna menambahkan pengaturan di satu terminal dan pengguna lain menghapusnya di terminal lain, di mana file akhir hanya mencerminkan penulis terakhir.
Jika alat Anda perlu mendukung pengubah konkuren (concurrent mutators), Anda memerlukan mekanisme koordinasi di atas penulisan atomik tersebut. File kunci advisori (advisory lock file) dapat bekerja untuk kasus-kasus sederhana. Vektor versi (version vectors) atau nomor revisi monotonik di dalam konfigurasi itu sendiri dapat membantu mendeteksi tabrakan (collisions) sehingga penulis kedua dapat mencoba lagi. Hal-hal ini menambah kompleksitas, dan kompleksitas adalah tempat di mana bug bersembunyi.
Itulah mengapa batasan itu penting. Sebuah blob JSON tunggal yang diperbarui satu proses sesekali adalah kandidat yang baik untuk penulisan file atomik. Begitu Anda mendapati diri Anda mengelola banyak rekaman, menegakkan skema, atau mengkhawatirkan mutasi konkuren, Anda telah melampaui kapasitas sistem berkas (filesystem). SQLite ada justru karena alasan ini. SQLite memberi Anda transaksi atomik, jurnal rollback, dan penanganan pembaca serta penulis konkuren yang tepat, semuanya di dalam satu file lokal host. Protokol file yang cerdas bukanlah sebuah database, dan Anda tidak seharusnya menghabiskan anggaran pemeliharaan untuk berpura-pura sebaliknya.
Pelajaran utamanya
Lain kali saat Anda menggunakan writeFile di dalam alat CLI, berhentilah sejenak. Serialisasi bukanlah bagian yang sulit. Durabilitaslah yang sulit. File konfigurasi terlalu kecil untuk di-stream dan terlalu penting untuk dipangkas (truncate). Tulis seluruh payload ke file pendamping tersembunyi, lakukan flush, lakukan commit dengan penggantian nama (rename), dan beri tahu direktori tentang hal tersebut. Pengguna Anda bisa saja menekan Ctrl-C, mencabut kabel daya, atau menutup layar laptop mereka. Saat mesin menyala kembali, file tersebut akan berisi dunia lama atau dunia baru. Tidak ada di antaranya.
