Escritura atómica de archivos en .NET: evitar archivos incompletos
Escribe primero en un archivo temporal, vacía los buffers y después reemplázalo o muévelo para evitar archivos parciales o dañados.
Escribir directamente en un archivo existente es sencillo, pero puede dejarlo vacío, incompleto o dañado si la aplicación se bloquea, el proceso se interrumpe o se produce un error de entrada/salida durante la escritura.
Un enfoque más seguro consiste en utilizar una escritura atómica de archivos: primero se escribe todo el contenido en un archivo temporal, se vacían los buffers y solo entonces se reemplaza el archivo de destino. Los lectores deberían ver el archivo anterior completo o el nuevo archivo completo, pero nunca una versión escrita parcialmente.
Objetivo
- No escribir nunca el contenido nuevo directamente sobre el archivo de destino.
- Crear el archivo temporal en el mismo directorio que el archivo de destino.
- Vaciar los buffers del archivo temporal completado antes de publicarlo.
- Usar
File.Replacepara archivos existentes yFile.Movepara la primera creación. - Eliminar los archivos temporales abandonados si la operación falla o se cancela.
Por qué sobrescribir directamente puede ser arriesgado
La escritura directa es cómoda:
await File.WriteAllTextAsync(
"settings.json",
json,
cancellationToken);
Sin embargo, al sobrescribir un archivo existente, normalmente se trunca antes de que el nuevo contenido se haya escrito por completo. Si el proceso se detiene durante la operación, el archivo de destino puede contener solo una parte del contenido nuevo o incluso ningún contenido útil.
Esto puede ser especialmente problemático para archivos de configuración, informes exportados, bases de datos locales, estados de aplicación, respuestas de API almacenadas en caché y documentos JSON que deben permanecer sintácticamente válidos en todo momento.
Flujo de una escritura atómica
- Crear un archivo temporal con un nombre único junto al archivo de destino.
- Escribir todo el contenido en el archivo temporal.
- Vaciar los buffers del writer y del sistema de archivos.
- Si el archivo de destino existe, reemplazarlo con
File.Replace. - Si el archivo de destino no existe, mover el archivo temporal a su ubicación definitiva.
- Eliminar el archivo temporal si se produce un error antes de publicarlo.
Importante: el archivo temporal se crea deliberadamente en el directorio de destino. Cuando ambos archivos se encuentran en el mismo volumen del sistema de archivos, la operación final de reemplazo o movimiento puede utilizar el comportamiento rename/replace del sistema de archivos, en lugar de convertirse en una operación de copia y eliminación.
Helper AtomicFileWriter
El helper siguiente escribe texto en UTF-8 sin marca de orden de bytes de forma predeterminada. Admite cancelación, creación opcional de copias de seguridad, flush al disco y limpieza de archivos temporales.
using System.Text;
public static class AtomicFileWriter
{
public static async Task WriteAllTextAsync(
string destinationPath,
string content,
Encoding? encoding = null,
string? backupPath = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
ArgumentNullException.ThrowIfNull(content);
var fullDestinationPath = Path.GetFullPath(destinationPath);
var directory = Path.GetDirectoryName(fullDestinationPath)
?? throw new InvalidOperationException(
"No se pudo determinar el directorio de destino.");
Directory.CreateDirectory(directory);
var destinationFileName = Path.GetFileName(fullDestinationPath);
var temporaryPath = Path.Combine(
directory,
$".{destinationFileName}.{Guid.NewGuid():N}.tmp");
var fullBackupPath = string.IsNullOrWhiteSpace(backupPath)
? null
: Path.GetFullPath(backupPath);
encoding ??= new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false);
try
{
await using (var stream = new FileStream(
temporaryPath,
new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
BufferSize = 64 * 1024,
Options = FileOptions.Asynchronous
}))
{
await using (var writer = new StreamWriter(
stream,
encoding,
bufferSize: 64 * 1024,
leaveOpen: true))
{
await writer.WriteAsync(
content.AsMemory(),
cancellationToken);
await writer.FlushAsync();
}
cancellationToken.ThrowIfCancellationRequested();
// Escribe los buffers intermedios del sistema de archivos en el disco.
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(fullDestinationPath))
{
File.Replace(
sourceFileName: temporaryPath,
destinationFileName: fullDestinationPath,
destinationBackupFileName: fullBackupPath,
ignoreMetadataErrors: false);
}
else
{
File.Move(
sourceFileName: temporaryPath,
destFileName: fullDestinationPath);
}
}
finally
{
// Después de un reemplazo o movimiento correcto,
// el archivo temporal deja de existir.
// Si la escritura falla o se cancela, se elimina aquí.
TryDelete(temporaryPath);
}
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Un error de limpieza no debe ocultar la excepción original.
}
}
}
Por qué se necesitan File.Replace y File.Move
File.Replace requiere que el archivo de destino ya exista.
Este método reemplaza el archivo de destino por el archivo temporal y puede conservar opcionalmente
la versión anterior como copia de seguridad.
Cuando el archivo de destino se crea por primera vez, todavía no existe ningún archivo que pueda reemplazarse.
En ese caso, File.Move publica el archivo temporal completado con su nombre definitivo.
if (File.Exists(fullDestinationPath))
{
File.Replace(
temporaryPath,
fullDestinationPath,
fullBackupPath);
}
else
{
File.Move(
temporaryPath,
fullDestinationPath);
}
Ejemplo: guardar configuraciones JSON de forma segura
El helper puede utilizarse para guardar un archivo de configuración JSON sin exponer a los lectores a un documento escrito parcialmente.
using System.Text.Json;
public sealed record ApplicationSettings(
string Theme,
string Language,
int PageSize);
var settings = new ApplicationSettings(
Theme: "Dark",
Language: "es",
PageSize: 25);
var json = JsonSerializer.Serialize(
settings,
new JsonSerializerOptions
{
WriteIndented = true
});
await AtomicFileWriter.WriteAllTextAsync(
destinationPath: "data/settings.json",
content: json,
backupPath: "data/settings.json.bak",
cancellationToken: cancellationToken);
Si settings.json ya existe, la versión anterior se guarda en
settings.json.bak antes de ser reemplazada por el archivo nuevo.
Ejemplo: guardar sin copia de seguridad
No indique ninguna ruta de copia de seguridad cuando no sea necesario conservar la versión anterior:
await AtomicFileWriter.WriteAllTextAsync(
destinationPath: "exports/report.json",
content: reportJson,
cancellationToken: cancellationToken);
Atomicidad y durabilidad son conceptos diferentes
Un reemplazo atómico se centra en lo que pueden observar los lectores: el archivo anterior completo o el archivo nuevo completo. Evita que el archivo de destino se actualice de forma gradual.
La durabilidad se refiere a la capacidad de los datos para sobrevivir a un fallo del sistema operativo
o a un corte repentino de energía. La llamada a Flush(flushToDisk: true) solicita al sistema operativo
que escriba los buffers intermedios en el disco, pero las garantías finales siguen dependiendo
del sistema operativo, el sistema de archivos, el dispositivo de almacenamiento y la configuración del hardware.
La concurrencia es un problema independiente
Un reemplazo atómico no coordina automáticamente varios writers. Si dos procesos escriben al mismo tiempo en el mismo destino, ambos pueden crear archivos temporales y competir para publicar su resultado.
Las aplicaciones con varios writers deberían añadir un mecanismo de coordinación, por ejemplo:
- Un
SemaphoreSlima nivel de aplicación para escrituras dentro del mismo proceso. - Un mutex con nombre para varios procesos ejecutados en la misma máquina.
- Un bloqueo de base de datos o un bloqueo distribuido cuando varias máquinas pueden modificar el mismo recurso.
- Una comprobación de versión o un token de concurrencia optimista para detectar actualizaciones obsoletas.
Mejoras habituales
- Devolver un resultado que incluya la ruta final, la ruta de la copia de seguridad y el número de bytes escritos.
- Limitar o eliminar periódicamente los archivos
.tmpabandonados después de una interrupción brusca del proceso. - Escribir contenido binario siguiendo el mismo patrón mediante un stream o
ReadOnlyMemory<byte>. - Comprobar que una ruta de copia de seguridad opcional se encuentra en un volumen de sistema de archivos compatible.
- Añadir reintentos solo para determinados errores transitorios de entrada/salida, sin volver a intentar todas las excepciones de forma indiscriminada.
TL;DR
- No sobrescriba directamente los archivos importantes.
- Escriba primero todo el contenido en un archivo temporal situado en el directorio de destino.
- Vacíe los buffers del archivo temporal antes de publicarlo.
- Utilice
File.Replacepara un destino existente yFile.Movepara un archivo nuevo. - Recuerde que un reemplazo atómico no resuelve por sí solo la coordinación entre varios writers.