Un archivo de configuración que funcionaba hace cinco segundos es ahora un fragmento de JSON roto de 347 bytes. Tu herramienta CLI no arrancará. El usuario, que simplemente pulsó Ctrl-C porque la actualización tardaba más de lo esperado, ahora se enfrenta a un rastro de la pila de errores (stack trace) que no pidió. Los dos kilobytes de configuración válida que existían antes de la escritura han desaparecido, reemplazados por el equivalente digital de un recibo a medio imprimir.
Esto sucede porque writeFile no es atómico. Abre la ruta existente, la trunca, transmite los datos desde Node hacia el caché de páginas del kernel y, finalmente, cierra el descriptor de archivo. En el momento en que ocurre la truncación, el contenido antiguo ya ha desaparecido. Todo lo que ocurre entre esa truncación y el close final es una ventana de vulnerabilidad. Un SIGINT, un corte de energía o el cierre repentino de la tapa de una laptop durante esa ventana deja al sistema de archivos con un desastre truncado. Incluso si Node informa que la Promise se ha resuelto, es posible que el sistema operativo todavía esté almacenando escrituras en memoria (buffering). Los métodos de conveniencia ocultan ese intervalo, pero no lo eliminan.
La solución no es escribir en el mismo lugar. La solución es separar el acto de escribir del acto de publicar.
Escribir en un archivo hermano y luego intercambiar
El patrón fiable consta de cinco pasos. Ninguno de ellos es complicado, pero juntos reducen la ventana de fallo de toda una escritura por flujo (streaming) a una única operación de metadatos del sistema de archivos.
Primero, serializa toda la carga útil en memoria. Haz esto antes de crear cualquier archivo temporal. Si JSON.stringify lanza una excepción porque alguien pasó un objeto circular, querrás que esa excepción se propague antes de tocar el disco.
Segundo, escribe los datos serializados en un archivo temporal ubicado en el mismo directorio que el destino. Usa un nombre aleatorio para que dos ejecuciones concurrentes no colisionen. Mantener el archivo temporal en el mismo directorio es importante porque rename solo es atómico dentro de un mismo sistema de archivos. Si tu archivo temporal reside en una partición diferente, el sistema operativo recurre a una secuencia de copiar y eliminar, lo que introduce sus propios modos de fallo y deja de ser atómico.
Tercero, pide al kernel que vacíe (flush) ese archivo temporal en el almacenamiento físico. El fsync de Node, expuesto aquí como el método sync en un filehandle, bloquea la ejecución hasta que los buffers se han volcado al hardware. Esto es lento, pero las escrituras de configuración ocurren con la suficiente poca frecuencia como para que la durabilidad valga los milisegundos.
Cuarto, renombra el archivo temporal sobre la ruta original. Tanto en sistemas POSIX como en Windows, este es el punto de confirmación (commit). Los lectores que abran la ruta original verán el archivo antiguo completo o el archivo nuevo completo. No hay un momento en el que un lector pueda abrir la ruta y observar un buffer a medio escribir.
Quinto, sincroniza el directorio padre. Esto captura un caso límite sutil. El renombramiento actualiza la entrada del directorio, pero los metadatos del propio directorio podrían estar en el caché de páginas del kernel. Una pérdida repentina de energía tras un renombramiento exitoso puede, a veces, dejar el sistema de archivos en un estado en el que la nueva referencia al inode nunca se registró de forma duradera. Sincronizar el directorio fuerza esa actualización de metadatos al disco y sella la transacción.
Una implementación concreta en Node.js
Así es como se ve ese patrón en la práctica utilizando únicamente la biblioteca estándar de 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;
}
}
Algunos detalles de este código merecen atención.
La bandera wx significa "escribir, pero fallar si el archivo ya existe". Esto protege contra una colisión de UUID o un archivo temporal abandonado de un proceso anterior que falló. Si alguien ha colocado un archivo malicioso donde debería estar tu archivo temporal, te enterarás de inmediato en lugar de sobrescribir lo que haya allí.
La máscara de permisos 0o600 crea el archivo temporal con permisos de solo lectura y solo escritura para el propietario. Los archivos de configuración suelen contener secretos, tokens de API o URLs de repositorios privados. No hay razón para permitir que otros usuarios del sistema echen un vistazo al archivo temporal mientras se está preparando.
Observa las llamadas a sync por separado, primero en el archivo y luego en el directorio. Muchos desarrolladores omiten la sincronización del directorio porque parece redundante. No lo es. Ext4, APFS y NTFS manejan las actualizaciones de directorio de forma diferente, pero comparten el hábito común de agrupar las escrituras de metadatos para mejorar el rendimiento. Si te importa sobrevivir a un corte de energía, la sincronización del directorio es el sello final.
La limpieza en el bloque catch es intencionadamente defensiva. Si algo lanza una excepción después de que se abre el manejador de archivo, el código intenta cerrar el manejador y eliminar el archivo temporal, silenciando cualquier error secundario para que la excepción original se propague limpiamente. No querrás que un error de permisos durante la limpieza enmascare el error real que causó el fallo.
Dónde deja de ayudar este patrón
El reemplazo atómico de archivos evita las escrituras incompletas. No evita las actualizaciones perdidas. Si dos instancias de tu CLI leen la misma configuración simultáneamente, ambas realizan sus ediciones en memoria, ambas escriben nuevos archivos temporales y ambas ejecutan sus renombrados, el segundo renombrado gana. El primer proceso no observó los cambios del segundo proceso. Dependiendo de tu aplicación, esto podría significar que un usuario añade un ajuste en una terminal y otro usuario lo elimina en otra, resultando en que el archivo final refleje solo al último escritor.
Si tu herramienta necesita admitir mutadores concurrentes, necesitarás un mecanismo de coordinación por encima de la escritura atómica. Un archivo de bloqueo de tipo advisory funciona para casos sencillos. Los vectores de versión o un número de revisión monotónico dentro de la propia configuración pueden ayudar a detectar colisiones para que el segundo escritor pueda reintentarlo. Esto añade complejidad, y la complejidad es donde se esconden los errores.
Por eso el límite es importante. Un único bloque JSON que un proceso actualiza ocasionalmente es un buen candidato para una escritura de archivo atómica. Una vez que te encuentres gestionando múltiples registros, imponiendo esquemas o preocupándote por las mutaciones concurrentes, habrás superado las capacidades del sistema de archivos. SQLite existe precisamente por esta razón. Te ofrece transacciones atómicas, diarios de reversión y un manejo adecuado de lectores y escritores concurrentes, todo dentro de un único archivo local del host. Un protocolo de archivos ingenioso no es una base de datos, y no deberías gastar presupuesto de mantenimiento pretendiendo lo contrario.
La verdadera conclusión
La próxima vez que recurras a writeFile dentro de una herramienta de CLI, detente. La serialización no es la parte difícil. La durabilidad lo es. Los archivos de configuración son demasiado pequeños para hacer streaming y demasiado importantes para truncarlos. Escribe toda la carga útil en un archivo hermano oculto, realiza un flush, confírmalo con un renombrado y notifícalo al directorio. Tus usuarios pueden presionar Ctrl-C, desenchufar el cable de alimentación o cerrar la tapa de su portátil. Cuando la máquina vuelva a encenderse, el archivo contendrá el mundo antiguo o el nuevo. Nada intermedio.
