Guía práctica de FileSystemWatcher - Cómo evitar pérdidas de eventos y duplicados

· Actualizado el: · · FileSystemWatcher, C#, .NET, Desarrollo en Windows, Integración de archivos, Diseño

FileSystemWatcher es la primera API que se plantea al vigilar cambios de archivos en .NET sobre Windows. Resulta útil porque permite recibir mediante eventos la creación, modificación, eliminación y cambio de nombre de archivos y directorios, pero si se usan Created o Changed asumiendo directamente que son notificaciones de finalización, es bastante habitual sufrir accidentes por pérdida de eventos, notificaciones duplicadas o lecturas erróneas de archivos a medio escribir.

En este artículo organizamos el uso y los puntos de atención de FileSystemWatcher, principalmente en el contexto de la integración de archivos mediante .NET sobre Windows. Además, dejamos disponible como referencia el concepto de control de exclusión que sirve de base, en Conocimientos básicos de control de exclusión en la integración de archivos - Buenas prácticas de bloqueo de archivos y claim atómico.

De hecho, es posible que Created se dispare antes de que termine la copia de un archivo, y Changed tampoco tiene por qué llegar una sola vez. Si los cambios se concentran en poco tiempo, el búfer interno puede desbordarse y provocar la pérdida de cambios individuales.

Por eso, el núcleo del diseño es este:

  • La notificación es solo el disparador
  • La verdad está en el reescaneo del directorio
  • La propiedad se obtiene mediante un claim atómico
  • Al final, todo se absorbe mediante idempotency

En el resto del artículo repasamos, siguiendo esta idea, los puntos donde se suele caer al incorporar FileSystemWatcher en la integración de archivos.

Además, el código que aparece en este artículo está publicado en GitHub como un conjunto de ejemplos que se puede compilar y ejecutar (una biblioteca, una demo de consola que funciona sobre un directorio temporal y pruebas unitarias que crean y modifican archivos de verdad para verificar los eventos).

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

Público objetivo y prerrequisitos

Está escrito para desarrolladores que, en .NET sobre Windows, escriben procesos que vigilan un directorio de recepción para incorporar archivos. Los ejemplos de código dan por supuesto C# / .NET 8 o posterior, pero la idea en sí no depende del lenguaje.

Este artículo usa exactamente la misma terminología que el artículo anterior enlazado arriba (control de exclusión en la integración de archivos). Términos como claim, idempotency, manifest y bundle aparecen sin explicación a partir del capítulo 4, así que, para que se pueda seguir el texto aunque no haya leído el artículo anterior, los resumimos primero en una línea cada uno.

Terminología que conviene tener clara de antemano

Término Significado
claim Tomar, de forma que ningún otro worker pueda interponerse, la propiedad de «esta soy yo quien la procesa». En la implementación se usa un rename de incoming/ a processing/<worker>/, y solo el único proceso cuyo rename tiene éxito se convierte en el propietario (4.3)
idempotency (idempotencia) La propiedad de que procesar el mismo objetivo dos o más veces produce el mismo resultado que procesarlo una sola vez. Puesto que se da por hecho que puede haber notificaciones duplicadas o reescaneos, al final es aquí donde se absorbe ese riesgo (4.5)
manifest Un archivo pequeño que se coloca junto con los datos principales y que describe su contenido. Si incluye la cantidad de elementos, un hash o la IdempotencyKey, el receptor puede determinar si «esto ya se procesó»
bundle La unidad que agrupa una sola entrega de integración. Si se coloca el cuerpo principal, el manifest y los archivos auxiliares en un mismo directorio, se puede tomar el claim de todo ese directorio con un único rename (4.3)
full rescan Volver a enumerar desde cero el directorio vigilado, sin fiarse de los eventos, para revisar de nuevo qué objetos se pueden procesar (4.4)
overflow El desbordamiento del búfer interno de FileSystemWatcher, que provoca la pérdida de notificaciones individuales. Se notifica mediante el evento Error (2.3)
ready El estado en el que se puede determinar que «ya se puede leer». No se decide por suposición, sino por la existencia del nombre final o de un archivo done / manifest (4.2)

Índice

  1. Primero, la conclusión (en pocas palabras)
    • 1.1. Primero, el código mínimo que funciona
  2. Patrones de malentendido habituales con FileSystemWatcher (diagrama)
    • 2.1. Creer que Created es una notificación de finalización
    • 2.2. Confiar en el número y el orden de los eventos Changed
    • 2.3. Perder cambios por desbordamiento del búfer interno
  3. Antipatrones
    • 3.1. Procesar directamente dentro del manejador de eventos
    • 3.2. Intentar reconstruir el estado real a partir de la secuencia de eventos
    • 3.3. Tratar como finalizado cuando Changed deja de llegar
    • 3.4. Creer que basta con aumentar InternalBufferSize
    • 3.5. Registrar Error en el log y no hacer nada más
  4. Buenas prácticas
    • 4.1. Reducir las notificaciones a una única «solicitud de reescaneo»
    • 4.2. El emisor debe declarar explícitamente la condición de finalización
    • 4.3. El receptor debe tomar el claim de forma atómica
    • 4.4. Ejecutar un full rescan al iniciar, ante un overflow o al reconectar
    • 4.5. Dar por hecho la idempotency (idempotencia)
  5. Pseudocódigo (extracto)
    • 5.1. Un patrón de fallo típico
    • 5.2. Un ejemplo en la dirección correcta (a grandes rasgos)
  6. Cómo elegir según el caso, a grandes rasgos
  7. Resumen
  8. Referencias

1. Primero, la conclusión (en pocas palabras)

  • Los eventos de FileSystemWatcher no son notificaciones de finalización, sino indicios de cambio
  • Created / Changed / Renamed pueden duplicarse, llegar en un orden distinto del esperado o perderse durante un overflow
  • Es más estable no hacer procesamiento pesado en el manejador de eventos y limitarse a acumular solicitudes de reescaneo
  • Lo básico es declarar explícitamente la finalización mediante temp -> close -> rename / replace o mediante done / manifest
  • Si hay varios workers, es necesario tomar el claim de forma atómica antes de leer
  • Ajustar InternalBufferSize es solo un apoyo. Al final, lo que realmente funciona es el full rescan junto con la idempotency

En resumen, se trata de no tratar FileSystemWatcher como un «flujo de historial verídico». Es más resistente a fallos si la notificación se mantiene simplemente como la señal de «ya toca ir a mirar».

1.1. Primero, el código mínimo que funciona

Para quienes todavía no han usado FileSystemWatcher, dejamos aquí la forma mínima que cubre solo el camino normal. Los capítulos que siguen son la historia de las trampas que empiezan justo donde estas diez líneas «funcionan sin más».

// C# / .NET 8, aplicación de consola. Forma mínima que solo confirma que llegan las notificaciones
using System.IO;

using var watcher = new FileSystemWatcher(@"C:\incoming")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};

watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");

watcher.EnableRaisingEvents = true; // Aquí comienza la vigilancia
Console.WriteLine("Presione Enter para salir");
Console.ReadLine();

Incluso en esta forma mínima, conviene tener claros desde el principio estos tres puntos:

  • Hasta que no se pone EnableRaisingEvents = true no llega ningún evento. Registrar solo el manejador no basta para que funcione
  • La vida de watcher es la vida de la aplicación. Si el alcance de una variable local termina y el objeto se elimina, las notificaciones se detienen ahí mismo. Si se necesita que quede residente, hay que guardarlo en algo que siga vivo, como un campo
  • El valor predeterminado de NotifyFilter es la combinación LastWrite | FileName | DirectoryName (véase FileSystemWatcher.NotifyFilter Property en el capítulo 8, Referencias). Declarar explícitamente qué se va a captar evita dudas al releer el código más adelante

Y lo importante es que este código solo confirma que «llegan los eventos». Con esta forma no se sabe si ya se puede leer el archivo en el momento de Created, ni si se están perdiendo notificaciones. A partir de aquí empieza lo importante.

2. Patrones de malentendido habituales en el uso de FileSystemWatcher (diagrama)

2.1. Creer que Created es una notificación de finalización

Esta es la trampa más fácil de entender. En una copia o una transferencia, Created se dispara en el instante en que se crea el archivo, y después puede seguir uno o más eventos Changed.

Created llega antes de que el archivo esté completoMuestra cómo, durante una copia o transferencia, el evento Created se dispara en cuanto aparece el nombre del archivo y el receptor lo abre mientras el emisor todavía está escribiendo el resto del contenido, lo que produce una lectura parcial"lado receptor""FileSystemWatcher""watched dir""lado emisor""lado receptor""FileSystemWatcher""watched dir""lado emisor"todavía en copiafaltan filas / JSON dañado / ZIP dañadocrea orders.csvCreatedOnCreatedabre y lee orders.csvescribe el restoChangedChanged

Created puede indicar que «el nombre ya es visible», pero no garantiza que «ya se pueda leer». Si se confunden ambas cosas, se termina pisando por otra vía el mismo problema del apartado 2.1 del artículo anterior.

2.2. Confiar en el número y el orden de los eventos Changed

Changed no tiene por qué llegar una sola vez. Incluso operaciones normales como mover o guardar un archivo pueden verse divididas en varios eventos. Y además, se puede llegar a captar también lo que toca un antivirus o un indexador.

Changed puede llegar varias veces y en cualquier ordenMuestra cómo una operación normal de guardado dispara Created, Changed y Renamed en varios pasos, y cómo un antivirus o un indexador puede generar Changed adicionales, de modo que ni el número ni el orden de los eventos son fiables"FileSystemWatcher""AV / indexer""watched dir""aplicación que guarda""FileSystemWatcher""AV / indexer""watched dir""aplicación que guarda"no siempre una sola vez ni en este ordenempieza a guardar report.xlsxCreatedChangedrename desde el archivo temporalRenamedChangedescaneo / lectura de atributosChanged

Suponer que «si llega un Changed, ya terminó» o que «después de Renamed ya no se vuelve a tocar» es bastante arriesgado.

Notas adicionales:

  • Un rename de archivo puede disparar Changed
  • RenamedEventArgs.Name puede ser null si el sistema operativo no logra hacer corresponder el nombre antiguo con el nuevo
  • Los archivos ocultos tampoco se ignoran. No sirve suponer que, por tener un nombre temporal oculto, no se van a ver
  • Si se renombra el propio directorio vigilado, ese cambio no se notifica

2.3. Perder cambios por desbordamiento del búfer interno

FileSystemWatcher tiene un búfer interno. Si los cambios se concentran en poco tiempo, este búfer se desborda y se pierden notificaciones individuales.

Overflow del búfer interno de FileSystemWatcherMuestra cómo una ráfaga de cambios en poco tiempo llena el búfer interno y, si el procesamiento no da abasto, se produce un overflow que dispara el evento Error, tras lo cual conviene desconfiar de la integridad del historial de eventos y hacer un full rescan del directorionomuchos cambios en poco tiempolas notificaciones se acumulan en el búfer interno¿el procesamiento da abasto?procesar cada evento en ordenoverflowevento Errorno confiar en la integridad del historial individualfull rescan del directorio

Lo importante aquí es que, cuando ocurre un overflow, no necesariamente se pierde un solo evento. La integridad misma de la secuencia de eventos individuales queda en duda, así que lo más sensato es revisar todo de nuevo sin más.

3. Antipatrones

3.1. Procesar directamente dentro del manejador de eventos

Esto es cargar demasiado sobre el evento: tanto la determinación de finalización como la toma de propiedad.

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // podría seguir copiándose
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // solo lo muestra
};

Hay dos problemas.

  • En el momento de Created, el contenido puede estar incompleto
  • No hay recuperación ante fallos ni ante un overflow

Lo adecuado para el manejador de eventos es apenas levantar una solicitud de reescaneo y devolver el control enseguida. Si aquí se empieza con E/S pesada o actualizaciones de base de datos, uno mismo se pone la soga al cuello en cuanto llega una ráfaga.

3.2. Intentar reconstruir el estado real a partir de la secuencia de eventos

Un diseño como «añadir al diccionario en Created, actualizar en Changed, eliminar en Deleted y sustituir la clave en Renamed» parece elegante a primera vista. Pero en cuanto entran en juego duplicados, eventos divididos, overflow o interferencias externas, la coherencia empieza a resquebrajarse poco a poco.

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;
}

Es más sólido volver a comprobar cada vez el estado real en disco que insistir en esta dirección. Lo importante en la integración de archivos es encontrar correctamente, en este mismo instante, qué objetos se pueden procesar, no reproducir con fidelidad el historial de eventos.

3.3. Tratar como finalizado cuando Changed deja de llegar

Es un diseño que huele igual que aquel «si el tamaño del archivo deja de crecer, se da por terminado» del artículo anterior. Parece cómodo, pero decide la finalización por suposición.

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

Con esto surgen problemas, por ejemplo, en casos como estos:

  • La copia de un archivo grande se pausa a medio camino
  • La aplicación emisora guarda en varias etapas
  • En un recurso compartido de red, las notificaciones parecen llegar con retraso
  • Un proceso externo reescribe después los atributos o la marca de tiempo

La finalización es más estable cuando se declara explícitamente, no cuando se supone.

3.4. Creer que basta con aumentar InternalBufferSize

Ajustar InternalBufferSize es importante, pero no es el núcleo del diseño.

  • El valor predeterminado es 8192 bytes
  • No se puede bajar de 4096 bytes ni superar 64 KB
  • Como el búfer usa non-paged memory, cuanto más se aumenta, menos ligero resulta hacerlo

Es decir, aunque se suba hasta 64 KB, en cuanto una ráfaga de notificaciones lo supere, se acabó. Y además, esto no resuelve ni un milímetro el problema de si algo es o no una notificación de finalización.

Antes de aumentar el búfer, hay cosas que conviene atender primero.

  • Acotar lo que se vigila con Filter / Filters
  • Reducir NotifyFilter al mínimo necesario
  • No poner IncludeSubdirectories en true sin necesidad
  • Aligerar el manejador de eventos
  • Incorporar full rescan e idempotency

3.5. Registrar Error en el log y no hacer nada más

Error no es del tipo de notificación que «aparece de vez en cuando pero no importa». Aquí se manifiestan situaciones como un buffer overflow o un fallo en la continuidad de la vigilancia.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // si esto termina aquí, se detectó la pérdida pero no se recupera de ella
};

Como mínimo, conviene llegar hasta aquí:

  • Solicitar un full rescan
  • Si la continuidad de la vigilancia resulta dudosa, considerar recrear el watcher
  • Dar por hecho que puede haber pérdidas y permitir reprocesar de forma idempotente

4. Buenas prácticas

4.1. Reducir las notificaciones a una única «solicitud de reescaneo»

Si se conecta Created / Changed / Deleted / Renamed / Error cada uno directamente a un procesamiento de negocio distinto, la visión de conjunto se vuelve confusa. Lo primero es reducir todos ellos a un único tipo de señal: «ve a mirar».

Las notificaciones se reducen a una solicitud de escaneoMuestra cómo los eventos Created, Changed, Deleted y Renamed, el evento Error u overflow, y el arranque de la aplicación convergen todos en una única solicitud de escaneo, que dispara un reescaneo del directorio, la enumeración de candidatos listos y el intento de claimCreated / Changed / Deleted / Renamedsolicitud de escaneoError / overflowstartupreescaneo del directorioenumerar candidatos readyintentar el claim

Puntos a tener en cuenta en la implementación:

  • En el manejador de eventos, limitarse a poner dirty = true y emitir la señal
  • Concentrar el escaneo en un solo worker
  • Durante una ráfaga, agrupar durante unos 100 a 300 ms antes de escanear una sola vez
  • Si llegan notificaciones adicionales durante el escaneo, volver a escanear una vez más al terminar

El valor de 100 a 300 ms del tercer punto no procede de ninguna norma ni de documentación oficial, sino que es un valor inicial basado en la experiencia operativa del autor. En la práctica, es más fiable decidirlo después de medir estos dos aspectos.

Qué medir Cómo decidirlo
El tiempo que tarda un escaneo Si el tiempo de espera es menor que esto, solo se acumulan más solicitudes de escaneo antes de que termine el actual. Se usa como referencia un límite inferior igual o mayor que el tiempo del propio escaneo
El retraso de detección que se puede tolerar El tiempo de espera se traduce directamente en retraso de detección. Si hay un requisito del tipo «procesar dentro de los n segundos posteriores a la colocación del archivo», el límite superior debe caber dentro de una fracción de ese margen

Por ejemplo, si un escaneo termina en 50 ms y basta con detectar dentro de 1 segundo, estos 100 a 300 ms encajan sin problema. Al contrario, si hay muchos archivos y un escaneo tarda varios segundos, es más eficaz revisar primero la construcción del propio escaneo (acotar el objetivo, mirar solo done, separar en subdirectorios) antes que alargar el tiempo de espera.

De este modo, ya lleguen 5 o 50 eventos, lo que finalmente se hace queda unificado en «mirar el estado real y buscar lo que está ready».

4.2. El emisor debe declarar explícitamente la condición de finalización

Si se controla también el lado emisor, es más eficaz ajustar el protocolo de publicación que esforzarse en determinar la finalización desde el lado de FileSystemWatcher.

El camino más sólido sigue siendo este:

  • Escribir todo el contenido con un nombre temp
  • Hacer close
  • Hacer rename / replace dentro del mismo sistema de archivos
  • Si es necesario, colocar por último un done / manifest
Publicación mediante temp, close y rename o replaceMuestra la secuencia recomendada para que el emisor declare la finalización, escribir todo el contenido en un archivo temporal, cerrarlo, renombrarlo o reemplazarlo con el nombre final y dejar un archivo done o manifest, de modo que el receptor solo tenga que mirar el nombre final o el archivo doneescribir todo el contenido en data.tmpflush / closerename / replace a data.csvcolocar data.done / manifest.jsonel receptor solo mira el nombre final o done

Es lo mismo que en el artículo anterior, pero esto realmente funciona. Conviene entender FileSystemWatcher no como una herramienta que inventa la finalización, sino como una herramienta que encuentra pronto una finalización ya declarada explícitamente.

4.3. El receptor debe tomar el claim de forma atómica

Aunque el reescaneo encuentre un candidato ready, si se va a leerlo directamente, varios workers pueden tomarlo a la vez. Por eso, antes de procesar, se toma el claim de forma atómica.

Toma atómica del claim mediante renameMuestra cómo dos workers intentan renombrar el mismo bundle order-123 hacia su propio directorio de procesamiento, y solo el que logra el rename primero obtiene la propiedad del bundle"processing/worker2""processing/worker1""incoming""scanner""processing/worker2""processing/worker1""incoming""scanner"solo el que tiene éxito primero obtiene la propiedaddescubre order-123rename order-123rename order-123

Tal como se mencionó también en el artículo anterior, el rename de incoming -> processing/<worker>/ es la forma más clara de hacerlo. En particular, si se agrupan el cuerpo principal, el manifest y los archivos auxiliares en un solo directorio, resulta cómodo porque se puede tomar el claim a nivel de bundle.

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

Así, basta con un único rename del directorio del bundle para tomar la propiedad.

4.4. Ejecutar un full rescan al iniciar, ante un overflow o al reconectar

Esto es bastante importante.

  • Los archivos que ya estaban colocados antes de que arrancara la aplicación no se captan mediante eventos
  • Si ocurre un overflow, la secuencia de eventos individuales deja de ser confiable
  • Cuando entran en juego un recurso compartido de red o una desconexión temporal, es más seguro asumir que «algo de ese intervalo» se perdió

Por eso, conviene incluir un full rescan al menos en estos momentos:

  • Al iniciar
  • Al recibir Error
  • Justo después de recrear el watcher
  • A intervalos regulares, como red de seguridad periódica

La idea aquí es: «el watcher da pistas sobre las diferencias, y el reescaneo restaura la consistencia».

4.5. Dar por hecho la idempotency (idempotencia)

Al usar FileSystemWatcher, se termina yendo a mirar el mismo objeto varias veces. Esto no es un error, sino algo que conviene aceptar como parte del diseño para ganar estabilidad.

En concreto, sería algo así:

  • Incluir una IdempotencyKey en el manifest
  • Si ya se procesó, no volver a ejecutar los efectos secundarios
  • Permitir contrastar si algo ya está archivado, ya registrado en la base de datos o ya se envió
  • Aunque se haga un full rescan, que el resultado sea solo «volver a mirar de forma segura lo mismo de siempre»

Intentar construir exactly-once basándose solo en los eventos resulta bastante difícil. En la práctica es más sólido aceptar at-least-once y cerrar el ciclo al final con idempotency.

5. Pseudocódigo (extracto)

5.1. Un patrón de fallo típico

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

watcher.Created += (_, e) =>
{
    // se asume que Created = notificación de finalización
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // llega varias veces, así que se procesa otra vez sin más
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // no se recupera
};

Hay cuatro problemas.

  • Se conectan Created / Changed directamente al procesamiento de negocio
  • No hay determinación de finalización
  • No se hace full rescan ante un overflow
  • No hay ningún mecanismo que impida procesar el mismo archivo varias veces

5.2. Un ejemplo en la dirección correcta (a grandes rasgos)

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); // startup scan

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

        // agrupa un poco la ráfaga de notificaciones
        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; // otro worker lo tomó antes
            }

            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(); // no perder la notificación que llegó durante el escaneo
        }
    }
}

Lo importante de este ejemplo no son los detalles de la API, sino el flujo.

  • Las notificaciones se reducen a un scan request
  • El escaneo encuentra lo que está ready
  • Se toma el claim
  • Se comprueba la idempotency
  • Se procesa, se registra y se mueve al archive

Aquí, los eventos de FileSystemWatcher no son más que un trigger.

Cabe aclarar que EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed, entre otros, son funciones a las que este artículo puso nombre solo para mostrar el flujo, y no forman parte de la API estándar de .NET. La forma que realmente se compila y funciona (una biblioteca, una demo de consola sobre un directorio temporal y pruebas unitarias que verifican los eventos) está en el conjunto de ejemplos mencionado al principio.

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

6. Cómo elegir según el caso, a grandes rasgos

  • Un único worker receptor / se puede ajustar también el lado emisor Empiece por temp -> close -> rename junto con un startup scan. Solo con esto ya se gana bastante estabilidad.

  • Hay varios workers receptores Además de lo anterior, conviene incorporar el claim rename de incoming -> processing.

  • Hay notificaciones frecuentes y numerosas Acote Filter / NotifyFilter / IncludeSubdirectories y reduzca al mínimo el manejador de eventos. El ajuste de InternalBufferSize viene después.

  • El overflow resulta problemático / no se permite ninguna pérdida Dé por hecho el full rescan, y si aun así resulta insuficiente, es mejor no apostarlo todo únicamente a FileSystemWatcher. Si el entorno se limita a Windows, el USN change journal también es una opción.

  • No se puede controlar cómo escribe el sistema de la otra parte Es más seguro pensar primero si se puede negociar el protocolo de publicación, en lugar de suplir la condición de finalización con suposiciones. Si no es posible, conviene inclinarse hacia un diseño que rebaje el nivel de garantía y reciba los archivos de forma idempotente.

Los dos últimos puntos son decisiones de repliegue bastante importantes. FileSystemWatcher es útil, pero no es un detector de la verdad todopoderoso.

¿En qué se diferencia el USN change journal?

El USN change journal es el registro de cambios que NTFS mantiene a nivel de volumen. Una notificación de directorio como la de FileSystemWatcher solo se puede recibir si la aplicación está en marcha en el instante en que ocurre el cambio, pero como el change journal deja su registro del lado del volumen, también se pueden releer más tarde, a partir de la última posición leída (el USN), los cambios ocurridos mientras la aplicación estaba detenida. La propia documentación de Microsoft señala, como debilidad de las notificaciones de directorio, la necesidad de «mantener la aplicación en ejecución en todo momento», y presenta el change journal como la forma de evitar ese problema.

Por otro lado, también aumenta la carga.

  FileSystemWatcher USN change journal
Unidad de vigilancia El directorio indicado (+ subdirectorios) Todo el volumen. El alcance necesario se acota uno mismo
Mientras la aplicación estaba detenida No se sabe. Se cubre con un full rescan Se puede releer a partir del registro
Pérdida de eventos Ocurre por overflow del búfer interno Los registros antiguos se eliminan al superar el límite del journal
Qué se necesita Solo la API de .NET Un handle del volumen y llamadas a FSCTL_*. Las operaciones administrativas, como crear o eliminar el journal, requieren derechos de administrador

En resumen, es una opción para cuando entran en juego requisitos como «no se puede mantener en ejecución todo el tiempo» o «se quieren captar también los cambios ocurridos durante una parada». Al contrario, si eso no hace falta, FileSystemWatcher combinado con full rescan resulta una implementación más directa.

7. Resumen

FileSystemWatcher no sustituye a una notificación de finalización. La verdad no está en la secuencia de eventos, sino en el estado que ahora mismo se ve en disco. La finalización se declara explícitamente mediante temp -> close -> rename / replace o mediante done / manifest, y la propiedad se decide tomando el claim de forma atómica. Ahí está el núcleo del diseño.

Procesar de inmediato en Created, confiar en el número o el orden de los eventos Changed, tratar como finalizado cuando Changed deja de llegar, quedarse tranquilo solo con InternalBufferSize, ver un Error y no recuperarse de él: todos estos son diseños que conviene evitar. En su lugar, se reducen las notificaciones a una solicitud de reescaneo, se incluye un full rescan al iniciar, ante un overflow o al reconectar, se toma la propiedad mediante un claim rename, y los duplicados y los reescaneos se absorben con idempotency.

En definitiva, en FileSystemWatcher el truco está en no confundir «haber recibido el evento» con «poder procesar». Con solo separar estas dos cosas, se reduce bastante ese tipo de procesos de vigilancia que fallan solo de vez en cuando.

8. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Se puede leer un archivo en el evento Created de FileSystemWatcher?
No. Created solo indica que «el nombre ya es visible»; no garantiza que «ya se pueda leer». En una copia o transferencia, Created se dispara en el instante en que se crea el archivo, y después puede seguir uno o más eventos Changed. Lo básico es que el emisor declare la finalización mediante temp -> close -> rename/replace o mediante done/manifest, y que el receptor se fije únicamente en el nombre final o en el archivo done.
¿FileSystemWatcher puede perder notificaciones?
Sí. Si el búfer interno (8192 bytes por defecto, con un mínimo de 4096 bytes y un máximo de 64 KB) se desborda, se pierden notificaciones individuales y se dispara el evento Error. Cuando ocurre un overflow, la integridad misma de la secuencia de eventos individuales queda en duda, así que lo más seguro es hacer un full rescan del directorio para revisar todo de nuevo. Conviene incluir un full rescan al iniciar, al recibir Error, justo después de recrear el watcher y también de forma periódica como red de seguridad.
¿Por qué llega el evento Changed varias veces?
Porque incluso operaciones normales como mover o guardar un archivo pueden aparecer divididas en varios eventos, y además se recogen los cambios que provoca un antivirus o un indexador al tocar el archivo. Diseñar confiando en el número o el orden de los eventos es peligroso. Lo más estable es reducir las notificaciones a un único tipo de señal, la «solicitud de reescaneo», concentrar el escaneo en un solo worker y, durante una ráfaga, agrupar los eventos durante unos 100 a 300 ms antes de escanear una sola vez.
¿Aumentar InternalBufferSize resuelve la pérdida de notificaciones?
No lo resuelve. Aunque se suba hasta 64 KB, si la ráfaga de notificaciones supera ese límite se seguirán perdiendo eventos, y esto no resuelve en nada el problema de si una notificación indica finalización o no. Además, como el búfer usa non-paged memory, aumentarlo no es algo que se pueda hacer a la ligera. El orden correcto es primero acotar lo que se vigila con Filter/NotifyFilter, revisar IncludeSubdirectories, aligerar el manejador de eventos e incorporar full rescan e idempotency.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog