Guía práctica de FileSystemWatcher - Cómo evitar pérdidas de eventos y duplicados
· Actualizado el: · Go Komura · 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
- Primero, la conclusión (en pocas palabras)
- 1.1. Primero, el código mínimo que funciona
- Patrones de malentendido habituales con
FileSystemWatcher(diagrama)- 2.1. Creer que
Createdes 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
- 2.1. Creer que
- 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
Changeddeja de llegar - 3.4. Creer que basta con aumentar
InternalBufferSize - 3.5. Registrar
Erroren el log y no hacer nada más
- 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)
- Pseudocódigo (extracto)
- 5.1. Un patrón de fallo típico
- 5.2. Un ejemplo en la dirección correcta (a grandes rasgos)
- Cómo elegir según el caso, a grandes rasgos
- Resumen
- Referencias
1. Primero, la conclusión (en pocas palabras)
- Los eventos de
FileSystemWatcherno son notificaciones de finalización, sino indicios de cambio Created/Changed/Renamedpueden 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 / replaceo mediantedone/ manifest - Si hay varios workers, es necesario tomar el claim de forma atómica antes de leer
- Ajustar
InternalBufferSizees 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 = trueno llega ningún evento. Registrar solo el manejador no basta para que funcione - La vida de
watcheres 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
NotifyFilteres la combinaciónLastWrite | 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.
sequenceDiagram
accTitle: Created llega antes de que el archivo esté completo
accDescr: Muestra 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
participant Sender as "lado emisor"
participant Shared as "watched dir"
participant W as "FileSystemWatcher"
participant Receiver as "lado receptor"
Sender->>Shared: crea orders.csv
Shared-->>W: Created
W-->>Receiver: OnCreated
Receiver->>Shared: abre y lee orders.csv
Note over Receiver: todavía en copia
Sender->>Shared: escribe el resto
Shared-->>W: Changed
Shared-->>W: Changed
Note over Receiver: faltan filas / JSON dañado / ZIP dañado
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.
sequenceDiagram
accTitle: Changed puede llegar varias veces y en cualquier orden
accDescr: Muestra 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
participant App as "aplicación que guarda"
participant Dir as "watched dir"
participant AV as "AV / indexer"
participant W as "FileSystemWatcher"
App->>Dir: empieza a guardar report.xlsx
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: rename desde el archivo temporal
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: escaneo / lectura de atributos
Dir-->>W: Changed
Note over W: no siempre una sola vez ni en este orden
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.Namepuede sernullsi 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.
flowchart LR
accTitle: Overflow del búfer interno de FileSystemWatcher
accDescr: Muestra 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 directorio
A["muchos cambios en poco tiempo"] --> B["las notificaciones se acumulan en el búfer interno"]
B --> C{"¿el procesamiento da abasto?"}
C -- sí --> D["procesar cada evento en orden"]
C -- no --> E["overflow"]
E --> F["evento Error"]
F --> G["no confiar en la integridad del historial individual"]
G --> H["full 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
8192bytes - No se puede bajar de
4096bytes ni superar64 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
NotifyFilteral mínimo necesario - No poner
IncludeSubdirectoriesentruesin 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».
flowchart LR
accTitle: Las notificaciones se reducen a una solicitud de escaneo
accDescr: Muestra 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 claim
A["Created / Changed / Deleted / Renamed"] --> Q["solicitud de escaneo"]
B["Error / overflow"] --> Q
C["startup"] --> Q
Q --> D["reescaneo del directorio"]
D --> E["enumerar candidatos ready"]
E --> F["intentar el claim"]
Puntos a tener en cuenta en la implementación:
- En el manejador de eventos, limitarse a poner
dirty = truey 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 / replacedentro del mismo sistema de archivos - Si es necesario, colocar por último un
done/ manifest
flowchart TD
accTitle: Publicación mediante temp, close y rename o replace
accDescr: Muestra 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 done
A["escribir todo el contenido en data.tmp"] --> B["flush / close"]
B --> C["rename / replace a data.csv"]
C --> D["colocar data.done / manifest.json"]
D --> E["el 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.
sequenceDiagram
accTitle: Toma atómica del claim mediante rename
accDescr: Muestra 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
participant Scan as "scanner"
participant IN as "incoming"
participant P1 as "processing/worker1"
participant P2 as "processing/worker2"
Scan->>IN: descubre order-123
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: solo el que tiene éxito primero obtiene la propiedad
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
IdempotencyKeyen 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/Changeddirectamente 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 -> renamejunto 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/IncludeSubdirectoriesy reduzca al mínimo el manejador de eventos. El ajuste deInternalBufferSizeviene 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
- El conjunto de código de ejemplo de este artículo (biblioteca, demo, pruebas unitarias) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- Artículo relacionado: 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
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.NotifyFilter Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Por qué usar .NET Generic Host y BackgroundService en una aplicación de escritorio
Resumimos cómo usar Generic Host y BackgroundService en herramientas y apps residentes de Windows para organizar el inicio, el procesamie...
Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos
Reglas de diseño en .NET/C# para evitar fallos y bloqueos intermitentes con hilos: usar Task en lugar de hilos propios, reducir el estado...
Usar WMI/CIM desde C# y PowerShell ── Guía práctica de obtención de información de hardware, monitorización de procesos y consultas remotas
WMI/CIM es la solución estándar para leer el número de serie, monitorizar el disco y detectar procesos. Cmdlets CIM, migración desde Get-...
Cómo versionar el esquema de la base de datos de una aplicación empresarial — Migraciones que evitan que «cada cliente tenga una base de datos distinta»
Guía práctica para versionar el esquema de bases de datos de aplicaciones empresariales dispersas entre clientes: PRAGMA user_version, mi...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Las herramientas de integración de archivos o de vigilancia que usan FileSystemWatcher son un tema habitual dentro del desarrollo de aplicaciones Windows.
Consultoría técnica y revisión de diseño
Si desea ordenar como diseño las medidas contra la pérdida de eventos, el reescaneo y la determinación de finalización, esto encaja bien como consultoría técnica y revisión de diseño.
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.