Guide pratique de FileSystemWatcher - Gérer les événements manqués et les doublons

· Mis à jour le: · · FileSystemWatcher, C#, .NET, Développement Windows, Intégration par fichiers, Conception

FileSystemWatcher est la première API à laquelle on pense pour surveiller les changements de fichiers dans .NET sous Windows. Elle permet de recevoir sous forme d’événements les créations, modifications, suppressions et renommages de fichiers et de répertoires, ce qui est pratique - mais si l’on traite Created ou Changed comme des notifications d’achèvement, on se fait assez couramment piéger par des événements manqués, des notifications en double et la lecture de fichiers à moitié écrits.

Cet article fait le point sur l’utilisation de FileSystemWatcher et ses pièges, en se plaçant principalement dans le contexte d’une intégration par fichiers en .NET sous Windows. Pour les notions sous-jacentes de contrôle d’exclusion, voir aussi Les bases du contrôle d’exclusion pour l’intégration par fichiers - bonnes pratiques de verrouillage de fichiers et de claim atomique.

En pratique, Created peut se déclencher alors que le fichier est encore en cours de copie, et rien ne garantit que Changed ne se déclenche qu’une seule fois. Quand les changements se concentrent sur une courte période, le buffer interne peut déborder et faire perdre certains changements individuels.

Le cœur de la conception tient donc en ceci.

  • Les notifications sont des déclencheurs
  • La vérité se trouve dans une réanalyse du répertoire
  • La propriété s’obtient par un claim atomique
  • L’idempotence rattrape ce qui reste

Dans le corps de l’article, nous passons en revue, avec cet état d’esprit, les pièges qui surviennent lorsqu’on intègre FileSystemWatcher dans un dispositif d’intégration par fichiers.

Le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble complet, compilable et exécutable (une bibliothèque, une démo console fonctionnant sur un répertoire temporaire, et des tests unitaires qui créent et modifient réellement des fichiers pour vérifier les événements).

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

Table des matières

  1. La conclusion d’abord (en une phrase)
  2. Schémas d’erreur courants avec FileSystemWatcher (diagrammes)
    • 2.1. Prendre Created pour une notification d’achèvement
    • 2.2. Se fier au nombre et à l’ordre des Changed
    • 2.3. Perdre des changements à cause d’un débordement du buffer interne
  3. Anti-patterns
    • 3.1. Traiter directement dans le gestionnaire d’événement
    • 3.2. Essayer de reconstruire l’état réel à partir de la séquence d’événements
    • 3.3. Considérer l’arrêt des Changed comme un achèvement
    • 3.4. Croire qu’augmenter InternalBufferSize règle le problème
    • 3.5. Se contenter de journaliser Error sans réagir
  4. Bonnes pratiques
    • 4.1. Replier les notifications en « demandes de réanalyse »
    • 4.2. Rendre l’achèvement explicite côté émetteur
    • 4.3. Le récepteur prend un claim de façon atomique
    • 4.4. Réanalyse complète au démarrage / en cas d’overflow / lors d’une reconnexion
    • 4.5. Partir du principe de l’idempotence
  5. Pseudocode (extraits)
    • 5.1. Le schéma d’échec typique
    • 5.2. Un exemple dans la bonne direction (esquissé rapidement)
  6. Comment choisir, à grands traits
  7. Conclusion
  8. Références

1. La conclusion d’abord (en une phrase)

  • Les événements de FileSystemWatcher ne sont pas des notifications d’achèvement, mais des signes de changement
  • Created / Changed / Renamed peuvent se dupliquer, arriver dans un ordre différent de ce qu’on imagine, ou être perdus en cas d’overflow
  • Il est plus stable de ne pas faire de traitement lourd dans le gestionnaire d’événement, et de se contenter d’empiler des demandes de réanalyse
  • L’achèvement doit en principe être rendu explicite via temp -> close -> rename / replace ou via done / manifest
  • S’il y a plusieurs workers, il faut prendre le claim de façon atomique avant de lire
  • Ajuster InternalBufferSize n’est qu’un appoint. Au final, ce qui fonctionne, ce sont la réanalyse complète (full rescan) et l’idempotence

En résumé, il ne faut pas traiter FileSystemWatcher comme un « flux d’historique fiable ». Les choses cassent bien moins souvent si l’on garde les notifications comme un simple signal du type « il est temps d’aller voir ».

2. Schémas d’erreur courants avec FileSystemWatcher (diagrammes)

2.1. Prendre Created pour une notification d’achèvement

C’est le piège le plus évident. Lors d’une copie ou d’un transfert, Created se déclenche au moment même où le fichier est créé, et un ou plusieurs Changed peuvent suivre ensuite.

RécepteurFileSystemWatcherwatched dirÉmetteurRécepteurFileSystemWatcherwatched dirÉmetteurCopie encore en coursLignes manquantes / JSON corrompu / ZIP corrompuCrée orders.csvCreatedOnCreatedOuvre et lit orders.csvÉcrit le resteChangedChanged

Created peut signifier que « le nom est devenu visible », mais cela ne garantit pas qu’« il est déjà possible de lire ». Confondre les deux, c’est retomber sur le même piège que la section 2.1 de l’article précédent, simplement par un autre chemin.

2.2. Se fier au nombre et à l’ordre des Changed

Rien ne garantit que Changed ne se déclenche qu’une seule fois. Même une opération ordinaire comme un déplacement ou un enregistrement peut apparaître découpée en plusieurs événements. Et l’on peut en plus capter les accès faits par un antivirus ou un indexeur.

FileSystemWatcherAV / indexeurwatched dirAppli qui enregistreFileSystemWatcherAV / indexeurwatched dirAppli qui enregistrePas nécessairement une seule fois, ni dans cet ordreCommence à enregistrer report.xlsxCreatedChangedRenomme depuis un fichier temporaireRenamedChangedScan / accès aux attributsChanged

Des attentes comme « un Changed suffit pour dire que c’est fini » ou « plus rien ne touche le fichier après Renamed » sont assez risquées.

Remarques complémentaires :

  • Un rename de fichier peut déclencher un Changed
  • RenamedEventArgs.Name peut être null si le système d’exploitation ne parvient pas à faire correspondre l’ancien et le nouveau nom
  • Les fichiers cachés (hidden file) ne sont pas non plus ignorés. Se dire « c’est un nom temp caché, donc il ne sera pas vu » ne tient pas
  • Si l’on renomme le répertoire surveillé lui-même, ce changement n’est pas notifié

2.3. Perdre des changements à cause d’un débordement du buffer interne

FileSystemWatcher possède un buffer interne. Quand les changements se concentrent sur une courte période, ce buffer déborde et des notifications individuelles sont perdues.

OuiNonBeaucoup de changements en peu de tempsLes notifications s'accumulent dans le buffer interneLe traitement suit-il le rythme ?Traiter les événements un par un, dans l'ordreOverflowÉvénement ErrorNe plus faire confiance à l'intégrité de l'historique événement par événementRéanalyse complète du répertoire

Ce qui compte ici, c’est que « un overflow ne fait pas nécessairement perdre un seul événement ». L’intégrité même de la séquence d’événements individuels devient douteuse, il vaut donc mieux tout revérifier honnêtement.

3. Anti-patterns

3.1. Traiter directement dans le gestionnaire d’événement

Cela fait porter beaucoup trop de poids aux événements : la détection de l’achèvement et l’acquisition de la propriété.

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // Peut-être encore en cours de copie
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // On se contente de l'afficher
};

Il y a deux problèmes.

  • Au moment de Created, le contenu peut encore être incomplet
  • Il n’y a aucune reprise en cas d’échec ou d’overflow

Le gestionnaire d’événement est à son meilleur quand il se contente de lever une demande de réanalyse et de revenir immédiatement. Si l’on commence ici des E/S lourdes ou des mises à jour de base de données, on se met soi-même en difficulté lors des rafales.

3.2. Essayer de reconstruire l’état réel à partir de la séquence d’événements

Une conception du type « ajouter dans un dictionnaire sur Created, mettre à jour sur Changed, supprimer sur Deleted, changer la clé sur Renamed » paraît élégante à première vue. Mais dès que des doublons, des découpages, un overflow ou des perturbations externes s’en mêlent, la cohérence finit par se dégrader.

switch (e.ChangeType)
{
    case WatcherChangeTypes.Created:
        state[e.FullPath] = Pending;
        break;
    case WatcherChangeTypes.Changed:
        state[e.FullPath] = Modified;
        break;
    case WatcherChangeTypes.Deleted:
        state.Remove(e.FullPath);
        break;
}

Plutôt que de s’acharner dans cette direction, il est plus robuste de revérifier à chaque fois ce qui existe réellement sur le disque. Car ce qui compte dans l’intégration par fichiers, c’est de trouver correctement ce qu’il est permis de traiter à cet instant précis, et non de reconstituer fidèlement l’historique des événements.

3.3. Considérer l’arrêt des Changed comme un achèvement

C’est une conception qui sent la même chose que le « la taille du fichier a arrêté de bouger, donc c’est fini » de l’article précédent. Cela paraît pratique, mais on décide de l’achèvement par supposition.

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

Cela pose problème par exemple dans les cas suivants.

  • La copie d’un gros fichier se met en pause en cours de route
  • L’application émettrice enregistre en plusieurs étapes
  • Sur un partage réseau, les notifications semblent arriver en retard
  • Un processus externe réécrit ensuite des attributs ou des horodatages

L’achèvement est plus stable quand il est rendu explicite plutôt que déduit par supposition.

3.4. Croire qu’augmenter InternalBufferSize règle le problème

Ajuster InternalBufferSize est important, mais ce n’est pas le cœur de la conception.

  • La valeur par défaut est 8192 octets
  • Impossible de descendre sous 4096 octets, et impossible de dépasser 64 Ko
  • Le buffer utilise de la non-paged memory, donc l’agrandir n’est pas un geste aussi anodin qu’il y paraît

Autrement dit, même en le portant jusqu’à 64 Ko, dès qu’une rafale de notifications dépasse cette limite, c’est terminé. Et cela ne résout pas d’un millimètre la question de savoir s’il s’agit ou non d’une notification d’achèvement.

Avant d’agrandir le buffer, il y a des choses à faire en premier.

  • Restreindre la surveillance avec Filter / Filters
  • Réduire NotifyFilter au strict minimum nécessaire
  • Ne pas mettre IncludeSubdirectories à true sans réfléchir
  • Alléger les gestionnaires d’événements
  • Mettre en place la réanalyse complète (full rescan) et l’idempotence

3.5. Se contenter de journaliser Error sans réagir

Error n’est pas le genre de notification qu’on peut « voir de temps en temps sans s’en soucier ». C’est là que remontent les débordements de buffer (buffer overflow) et les situations où la poursuite de la surveillance échoue.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // S'arrêter ici, c'est remarquer la perte sans jamais s'en remettre
};

Au minimum, voici jusqu’où il faut aller.

  • Demander une réanalyse complète (full rescan)
  • Si la poursuite de la surveillance semble compromise, envisager aussi de recréer le watcher
  • Rendre le retraitement idempotent, en partant du principe que des événements ont pu être perdus

4. Bonnes pratiques

4.1. Replier les notifications en « demandes de réanalyse »

Câbler Created / Changed / Deleted / Renamed / Error chacun directement vers un traitement métier séparé rend les choses illisibles. Il faut d’abord tout replier en un seul type de signal : « va voir ».

Created / Changed / Deleted / Renameddemande de réanalyseError / overflowdémarrageRéanalyser le répertoireÉnumérer les candidats prêtsTenter un claim

Points d’implémentation :

  • Dans le gestionnaire d’événement, se contenter de mettre dirty = true et de lever un signal
  • Concentrer l’analyse sur un seul worker
  • En cas de rafale, regrouper pendant environ 100 à 300 ms avant de lancer une seule analyse
  • Si de nouvelles notifications arrivent pendant l’analyse, relancer une analyse une fois celle-ci terminée

Ainsi, que l’on reçoive 5 ou 50 événements, l’action finale reste unifiée : « regarder ce qui existe réellement et chercher ce qui est prêt ».

4.2. Rendre l’achèvement explicite côté émetteur

Si l’on maîtrise aussi le côté émetteur, il est plus efficace de corriger le protocole de publication plutôt que de s’acharner à détecter l’achèvement du côté de FileSystemWatcher.

La voie royale reste, sans surprise, celle-ci.

  • Écrire tout le contenu sous un nom temp
  • Faire un close
  • rename / replace sur le même système de fichiers
  • Si nécessaire, déposer un done / manifest en dernier
Écrire tout le contenu dans data.tmpflush / closerename / replace vers data.csvDéposer data.done / manifest.jsonLe récepteur ne surveille que les noms finaux ou les fichiers done

C’est la même chose que dans l’article précédent, mais c’est vraiment ce qui fait effet ici. Il est plus juste de voir FileSystemWatcher non pas comme un outil qui invente l’achèvement, mais comme un outil qui repère plus tôt un achèvement explicitement déclaré.

4.3. Le récepteur prend un claim de façon atomique

Même quand une réanalyse trouve un candidat prêt, aller le lire directement permet à plusieurs workers de s’en saisir en même temps. Il faut donc prendre le claim de façon atomique avant de traiter.

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscannerSeul celui qui réussit en premier obtient la propriétéTrouve order-123rename order-123rename order-123

Comme mentionné dans l’article précédent, le rename incoming -> processing/<worker>/ est le plus clair. Regrouper en particulier le corps du fichier + le manifest + les fichiers auxiliaires dans un seul répertoire est particulièrement pratique, car cela permet ensuite de faire le claim par bundle.

incoming/
  order-123/
    payload.csv
    manifest.json

Ainsi, un seul rename du répertoire du bundle suffit à obtenir la propriété.

4.4. Réanalyse complète au démarrage / en cas d’overflow / lors d’une reconnexion

Ceci est assez important.

  • Les fichiers déjà présents avant le démarrage de l’application ne sont pas captés par les événements
  • Une fois qu’un overflow s’est produit, la séquence d’événements individuels devient difficile à croire
  • Quand un partage réseau ou une déconnexion temporaire entre en jeu, il est plus sûr de partir du principe qu’« un événement survenu entre-temps » a pu être manqué

Il vaut donc mieux prévoir une réanalyse complète au moins aux moments suivants.

  • Au démarrage
  • À la réception d’Error
  • Juste après avoir recréé le watcher
  • Périodiquement, à intervalle régulier, par précaution

La philosophie ici est la suivante : « le watcher donne des indices sur les différences, la réanalyse restaure la cohérence ».

4.5. Partir du principe de l’idempotence

Avec FileSystemWatcher, on finit par examiner plusieurs fois la même cible. Ce n’est pas un bug ; il est plus stable de l’accepter comme un élément de conception.

Concrètement, cela donne à peu près ceci.

  • Placer une IdempotencyKey dans le manifest
  • Ne pas rejouer les effets de bord si le traitement a déjà eu lieu
  • Rendre vérifiables les statuts « déjà archivé », « déjà enregistré en base » et « déjà envoyé »
  • Faire en sorte qu’une réanalyse complète se limite à « revoir en toute sécurité la même chose une fois de plus »

Vouloir construire de l’exactly-once en s’appuyant uniquement sur des événements devient vite pénible. En pratique, il est plus solide d’accepter l’at-least-once et de refermer la boucle avec l’idempotence.

5. Pseudocode (extraits)

5.1. Le schéma d’échec typique

using var watcher = new FileSystemWatcher(incomingDir)
{
    Filter = "*.csv",
    IncludeSubdirectories = false,
    EnableRaisingEvents = true,
    InternalBufferSize = 64 * 1024
};

watcher.Created += (_, e) =>
{
    // Suppose que Created = notification d'achèvement
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // Ça se déclenche plusieurs fois, alors on retraite à chaque fois
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // Aucune reprise
};

Il y a quatre problèmes.

  • Created / Changed sont câblés directement vers le traitement métier
  • Il n’y a pas de détection de l’achèvement
  • Pas de réanalyse complète en cas d’overflow
  • Aucun mécanisme pour arrêter de traiter le même fichier à répétition

5.2. Un exemple dans la bonne direction (esquissé rapidement)

private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;

void OnAnyChange(object? sender, FileSystemEventArgs e)
{
    RequestScan(full: false);
}

void OnRenamed(object? sender, RenamedEventArgs e)
{
    RequestScan(full: false);
}

void OnError(object? sender, ErrorEventArgs e)
{
    Log(e.GetException());
    RequestScan(full: true);
}

void RequestScan(bool full)
{
    if (full)
    {
        Interlocked.Exchange(ref _fullRescanRequested, 1);
    }

    if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
    {
        _scanSignal.Release();
    }
}

async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
    RequestScan(full: true); // analyse au démarrage

    while (!cancellationToken.IsCancellationRequested)
    {
        await _scanSignal.WaitAsync(cancellationToken);

        // Regrouper un peu les rafales de notifications
        await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);

        Interlocked.Exchange(ref _scanRequested, 0);
        bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;

        foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
        {
            var claimedPath = Path.Combine(processingDir, bundle.Name);

            if (!TryClaimByRename(bundle.Path, claimedPath))
            {
                continue; // Un autre worker l'a déjà pris
            }

            var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));

            if (AlreadyProcessed(manifest.IdempotencyKey))
            {
                MoveToArchive(claimedPath, archiveDir);
                continue;
            }

            ProcessBundle(claimedPath);
            RecordProcessed(manifest.IdempotencyKey);
            MoveToArchive(claimedPath, archiveDir);
        }

        if (Volatile.Read(ref _scanRequested) == 1)
        {
            _scanSignal.Release(); // Ne pas perdre les notifications arrivées pendant l'analyse
        }
    }
}

Ce qui compte dans cet exemple, ce n’est pas le détail de l’API, mais le flux.

  • Replier les notifications en demandes de réanalyse
  • Trouver ce qui est prêt par l’analyse
  • Prendre un claim
  • Vérifier l’idempotence
  • Traiter, enregistrer, puis déplacer vers l’archive

Les événements de FileSystemWatcher ne sont ici rien de plus qu’un trigger.

6. Comment choisir, à grands traits

  • Un seul worker récepteur / vous pouvez aussi corriger le côté émetteur Commencez par temp -> close -> rename et une analyse au démarrage. Cela suffit déjà à obtenir une bonne stabilité.

  • Plusieurs workers récepteurs En plus de ce qui précède, il vaut mieux ajouter le claim rename incoming -> processing.

  • Notifications fréquentes et nombreuses Restreignez Filter / NotifyFilter / IncludeSubdirectories et réduisez les gestionnaires d’événements au strict minimum. L’ajustement d’InternalBufferSize vient après.

  • Les overflow posent problème / aucune perte n’est tolérable Basez-vous sur la réanalyse complète, et si cela reste insuffisant, il vaut mieux ne pas tout miser sur FileSystemWatcher seul. Si vous êtes limité à Windows, le USN change journal est aussi une option.

  • Vous ne contrôlez pas la manière dont le système en face écrit ses fichiers Plutôt que de combler par supposition les conditions d’achèvement, il est plus sûr d’examiner d’abord s’il est possible de négocier le protocole de publication. Si ce n’est pas possible, abaissez le niveau de garantie et orientez-vous vers une conception de réception idempotente.

Les deux derniers points sont des critères de repli assez importants. FileSystemWatcher est pratique, mais ce n’est pas un détecteur de vérité universel.

7. Conclusion

FileSystemWatcher ne remplace pas une notification d’achèvement. La vérité ne réside pas dans la séquence d’événements, mais dans l’état actuellement visible sur le disque. Rendez l’achèvement explicite via temp -> close -> rename / replace ou via done / manifest, et décidez de la propriété en prenant un claim de façon atomique. C’est là que se trouve le cœur de la conception.

Traiter immédiatement sur Created, se fier au nombre ou à l’ordre des Changed, considérer l’arrêt des Changed comme un achèvement, se rassurer avec le seul InternalBufferSize, voir Error sans jamais s’en remettre - ce sont toutes des conceptions à éviter. À la place, repliez les notifications en demandes de réanalyse, prévoyez une réanalyse complète au démarrage / en cas d’overflow / lors d’une reconnexion, prenez la propriété par un claim rename, et absorbez les doublons et les réanalyses avec l’idempotence.

En somme, avec FileSystemWatcher, l’astuce consiste à ne jamais confondre « avoir reçu un événement » et « avoir le droit de traiter ». Il suffit de séparer ces deux choses pour réduire nettement ce type de traitement de surveillance qui casse seulement de temps en temps.

8. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Peut-on lire le fichier dès l'événement Created de FileSystemWatcher ?
Non. Created indique seulement que « le nom est devenu visible », il ne garantit pas qu'« il est déjà possible de lire ». Lors d'une copie ou d'un transfert, Created se déclenche au moment même où le fichier est créé, et un ou plusieurs Changed peuvent suivre ensuite. L'achèvement doit être rendu explicite côté émetteur via temp -> close -> rename/replace ou via un fichier done/manifest, et le récepteur doit se contenter de surveiller le nom final ou le fichier done.
FileSystemWatcher peut-il manquer des notifications ?
Oui. Quand le buffer interne (8192 octets par défaut, impossible de descendre sous 4096 octets, plafonné à 64 Ko) déborde, des notifications individuelles sont perdues et un événement Error se déclenche. Une fois qu'un overflow s'est produit, l'intégrité même de la séquence d'événements individuels devient douteuse ; il est donc plus sûr de faire une réanalyse complète (full rescan) du répertoire pour tout revérifier. Il faut aussi prévoir une réanalyse complète au démarrage, à la réception d'un Error, juste après avoir recréé le watcher, et périodiquement par précaution.
Pourquoi l'événement Changed arrive-t-il plusieurs fois ?
Parce que même une opération ordinaire comme un déplacement ou un enregistrement peut apparaître découpée en plusieurs événements, et qu'en plus, on capte aussi les accès faits par un antivirus ou un indexeur. Une conception qui se fie au nombre ou à l'ordre des événements est dangereuse. Il est plus stable de replier toutes les notifications en un seul type de signal — « demande de réanalyse » —, de concentrer l'analyse sur un seul worker, et de regrouper les rafales pendant environ 100 à 300 ms avant de lancer une seule analyse.
Augmenter InternalBufferSize suffit-il à résoudre les pertes de notifications ?
Non. Même en le portant jusqu'à 64 Ko, une rafale de notifications qui dépasse cette limite continuera à provoquer des pertes, et cela ne résout en rien la question de savoir si une notification signale réellement un achèvement. Le buffer utilisant de la non-paged memory, l'agrandir n'est pas non plus un geste anodin. L'ordre à suivre est plutôt : restreindre d'abord la surveillance avec Filter/NotifyFilter, revoir IncludeSubdirectories, alléger les gestionnaires d'événements, puis mettre en place la réanalyse complète (full rescan) et l'idempotence.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog