Chargement...

Écriture atomique de fichiers en .NET : éviter les fichiers incomplets

Écrivez d’abord dans un fichier temporaire, videz les buffers, puis remplacez ou déplacez le fichier pour éviter les écritures partielles.

Écrire directement dans un fichier existant est simple, mais peut laisser le fichier vide, incomplet ou corrompu si l’application plante, si le processus est interrompu ou si une erreur d’entrée/sortie survient pendant l’écriture.

Une approche plus sûre consiste à utiliser une écriture atomique de fichier : écrivez d’abord tout le contenu dans un fichier temporaire, videz les buffers, puis remplacez seulement ensuite le fichier de destination. Les lecteurs doivent voir soit l’ancienne version complète, soit la nouvelle version complète, mais jamais un fichier partiellement écrit.


Objectif


Pourquoi l’écrasement direct peut être risqué

L’écriture directe est pratique :


await File.WriteAllTextAsync(
    "settings.json",
    json,
    cancellationToken);

Cependant, lorsqu’un fichier existant est écrasé, il est généralement tronqué avant que le nouveau contenu ne soit entièrement écrit. Si le processus s’arrête en cours d’opération, le fichier de destination peut ne contenir qu’une partie du nouveau contenu, voire aucun contenu exploitable.

Cela peut être particulièrement problématique pour les fichiers de configuration, les rapports exportés, les bases de données locales, l’état d’une application, les réponses d’API mises en cache et les documents JSON qui doivent toujours rester syntaxiquement valides.


Déroulement d’une écriture atomique

  1. Créer un fichier temporaire au nom unique à côté du fichier de destination.
  2. Écrire l’intégralité du contenu dans le fichier temporaire.
  3. Vider les buffers du writer et du système de fichiers.
  4. Si la destination existe, la remplacer avec File.Replace.
  5. Si la destination n’existe pas, déplacer le fichier temporaire vers son emplacement définitif.
  6. Supprimer le fichier temporaire si une erreur survient avant sa publication.

Important : le fichier temporaire est volontairement créé dans le répertoire de destination. Lorsque les deux fichiers se trouvent sur le même volume du système de fichiers, l’opération finale de remplacement ou de déplacement peut utiliser le comportement rename/replace du système de fichiers, au lieu de se transformer en opération de copie puis suppression.


Helper AtomicFileWriter

Le helper ci-dessous écrit par défaut le texte en UTF-8 sans marque d’ordre des octets. Il prend en charge l’annulation, la création facultative d’une sauvegarde, le flush sur le disque et le nettoyage des fichiers temporaires.


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(
                "Le répertoire de destination n’a pas pu être déterminé.");

        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();

                // Écrit les buffers intermédiaires du système de fichiers sur le disque.
                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
        {
            // Après un remplacement ou un déplacement réussi,
            // le fichier temporaire n’existe plus.
            // Si l’écriture échoue ou est annulée, il est supprimé ici.
            TryDelete(temporaryPath);
        }
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
                File.Delete(path);
        }
        catch
        {
            // Une erreur de nettoyage ne doit pas masquer l’exception d’origine.
        }
    }
}

Pourquoi File.Replace et File.Move sont tous les deux nécessaires

File.Replace exige que le fichier de destination existe déjà. Cette méthode remplace la destination par le fichier temporaire et peut, si nécessaire, conserver l’ancienne version sous forme de sauvegarde.

Lorsque le fichier de destination est créé pour la première fois, aucun fichier ne peut encore être remplacé. Dans ce cas, File.Move publie le fichier temporaire terminé sous son nom définitif.


if (File.Exists(fullDestinationPath))
{
    File.Replace(
        temporaryPath,
        fullDestinationPath,
        fullBackupPath);
}
else
{
    File.Move(
        temporaryPath,
        fullDestinationPath);
}

Exemple : enregistrer des paramètres JSON en toute sécurité

Le helper peut être utilisé pour enregistrer un fichier de configuration JSON sans exposer les lecteurs à un document partiellement écrit.


using System.Text.Json;

public sealed record ApplicationSettings(
    string Theme,
    string Language,
    int PageSize);

var settings = new ApplicationSettings(
    Theme: "Dark",
    Language: "fr",
    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 existe déjà, l’ancienne version est enregistrée dans settings.json.bak avant d’être remplacée par le nouveau fichier.


Exemple : enregistrer sans sauvegarde

N’indiquez aucun chemin de sauvegarde si vous n’avez pas besoin de conserver la version précédente :


await AtomicFileWriter.WriteAllTextAsync(
    destinationPath: "exports/report.json",
    content: reportJson,
    cancellationToken: cancellationToken);

Atomicité et durabilité sont deux notions différentes

Un remplacement atomique concerne ce que les lecteurs peuvent observer : soit l’ancien fichier complet, soit le nouveau fichier complet. Il empêche la destination d’être mise à jour progressivement.

La durabilité concerne la capacité des données à survivre à un crash du système d’exploitation ou à une coupure de courant soudaine. L’appel à Flush(flushToDisk: true) demande au système d’exploitation d’écrire les buffers intermédiaires sur le disque, mais les garanties finales dépendent toujours du système d’exploitation, du système de fichiers, du périphérique de stockage et de la configuration matérielle.


La concurrence est un problème distinct

Un remplacement atomique ne coordonne pas automatiquement plusieurs writers. Si deux processus écrivent simultanément vers la même destination, ils peuvent tous les deux créer un fichier temporaire et entrer en concurrence pour publier leur résultat.

Les applications comportant plusieurs writers doivent ajouter un mécanisme de coordination, par exemple :


Améliorations courantes


TL;DR

  • N’écrasez pas directement les fichiers importants.
  • Écrivez d’abord tout le contenu dans un fichier temporaire situé dans le répertoire de destination.
  • Videz les buffers du fichier temporaire avant de le publier.
  • Utilisez File.Replace pour une destination existante et File.Move pour un nouveau fichier.
  • Gardez à l’esprit qu’un remplacement atomique ne résout pas à lui seul la coordination entre plusieurs writers.