Cargando...

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


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

  1. Crear un archivo temporal con un nombre único junto al archivo de destino.
  2. Escribir todo el contenido en el archivo temporal.
  3. Vaciar los buffers del writer y del sistema de archivos.
  4. Si el archivo de destino existe, reemplazarlo con File.Replace.
  5. Si el archivo de destino no existe, mover el archivo temporal a su ubicación definitiva.
  6. 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:


Mejoras habituales


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.Replace para un destino existente y File.Move para un archivo nuevo.
  • Recuerde que un reemplazo atómico no resuelve por sí solo la coordinación entre varios writers.