Observar Cambios en Archivos
Cómo monitorear cambios en el sistema de archivos en tiempo real. Incluye watchers a nivel kernel en Python, JavaScript, Java y Bash, con debounce, manejo de OVERFLOW y fallback por polling.
Visión General
Los watchers del sistema de archivos permiten que tu programa reaccione en el momento en que un archivo se crea, modifica, elimina o renombra. Sin un bucle de polling quemando CPU en llamadas a stat(). Cada SO expone una API de notificación a nivel kernel: inotify en Linux, FSEvents en macOS y ReadDirectoryChangesW en Windows. Las bibliotecas de esta receta envuelven esas APIs para que los eventos lleguen a tu proceso en lugar de tener que ir a buscarlos.
Abajo está el watcher idiomático para Python, JavaScript y Java, más versiones listas para producción con debounce, registro recursivo y el manejo de errores que los ejemplos mínimos dejan afuera.
Cuándo Usar
Usa un watcher cuando:
- Construyes herramientas de desarrollo — servidores con hot-reload, compilación al guardar, re-ejecución de tests.
- Un directorio actúa como zona de entrega: subidas de archivos, exports o logs entrantes que deben disparar un pipeline.
- Necesitas reacción casi inmediata; un cron job o un bucle de escaneo cada N segundos es demasiado lento o demasiado caro.
Evítalo cuando:
- El directorio está en un recurso de red (NFS, SMB, SSHFS). La mayoría de los sistemas de archivos de red no propagan eventos del kernel entre máquinas, así que necesitarás polling.
- El árbol es enorme. Los watchers recursivos consumen un handle por directorio, y límites como
fs.inotify.max_user_watchesde Linux fallarán en voz alta (o peor, descartarán eventos) al superarse. - Estás dentro de un contenedor. Los bind mounts de Docker no reenvían los eventos del sistema de archivos del host al invitado en todos los setups, así que los watchers a menudo parecen muertos dentro de contenedores.
Solución
Python — watchdog
watchdog es el watcher multiplataforma estándar de Python. Elige automáticamente el mejor backend por SO.
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class Handler(FileSystemEventHandler):
def on_modified(self, event):
if not event.is_directory:
print(f"Modificado: {event.src_path}")
observer = Observer()
observer.schedule(Handler(), path='./watched', recursive=True)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
JavaScript — fs.watch
El fs.watch integrado de Node no necesita dependencias, pero trátalo como una envoltura delgada: recursive solo funciona en Windows y macOS en versiones viejas de Node (el soporte en Linux llegó en Node 20), y los nombres de eventos que reporta varían por plataforma. Para cualquier cosa más allá de un script rápido, usa chokidar; el ejemplo avanzado de abajo muestra por qué.
const fs = require('fs');
// Observar archivo o directorio
// NOTA: { recursive: true } requiere Node 20+ en Linux
const watcher = fs.watch('./watched', { recursive: true }, (eventType, filename) => {
console.log(`${eventType}: ${filename}`);
});
// Limpieza
process.on('SIGINT', () => watcher.close());
Java — WatchService
El WatchService de Java NIO está en el JDK desde Java 7. Mapea a las mismas APIs nativas; consulta el tutorial oficial de Oracle para el contrato completo.
import java.nio.file.*;
public class FileWatcher {
public static void watch(Path path) throws Exception {
WatchService watchService = FileSystems.getDefault().newWatchService();
path.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
while (true) {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
continue; // eventos perdidos; re-escanear el directorio
}
System.out.println(event.kind() + ": " + event.context());
}
if (!key.reset()) break; // la clave ya no es válida
}
}
}
Explicación
Un watcher registra interés en el kernel una sola vez, y el kernel empuja eventos a tu proceso. Por eso es barato comparado con polling: no hay syscalls hasta que algo realmente pasa.
Tres detalles importan más que el camino feliz:
- Los eventos se pueden perder. Toda API del kernel tiene una cola acotada. Si tu proceso tarda en drenarla, Linux entrega
IN_Q_OVERFLOWy Java entregaOVERFLOW. Maneja siempre ese evento, normalmente re-escaneando el directorio, o vas a perder cambios en silencio. - Los editores escriben atómicamente. Muchos editores guardan escribiendo un archivo temporal y renombrándolo sobre el original. Lo que parece un solo “guardar” llega como create + delete + rename en algunas plataformas, así que un handler ingenuo dispara varias veces por cada guardado. Para eso sirve el debounce de los ejemplos avanzados.
- Los watchers no sobreviven la frontera. Los sistemas de archivos de red y los bind mounts de contenedores rompen el modelo “el kernel te empuja” porque los eventos pasan en otro kernel. Ahí, la única opción confiable es polling:
chokidarlo soporta conusePolling: truey Java ofreceFileAlterationMonitor.
Variantes
| Tecnología | Enfoque | Notas |
|---|---|---|
| Python | watchdog | Multiplataforma, maneja casos límite como renombres rápidos |
| Python | watchfiles | Núcleo en Rust (notify-rs), amigable con async; es lo que usa uvicorn --reload |
| JavaScript | chokidar | Más confiable que fs.watch; normaliza eventos y soporta polling |
| Java | Apache Commons IO FileAlterationMonitor | Fallback por polling para JDKs antiguos o unidades de red |
| Linux/Bash | inotifywait | inotify crudo desde scripts de shell; ver el ejemplo avanzado |
Lo que funciona
- Aplica debounce a eventos rápidos — los editores y las herramientas de build suelen disparar varias escrituras por cada guardado lógico.
- Maneja siempre el evento
error,OVERFLOWy las excepciones deWatchService; un watcher silencioso es peor que no tener watcher. - Usa watchers recursivos con moderación; cada directorio cuesta un handle.
- Filtra por extensión para ignorar archivos temporales (
.tmp,.swp, lock files de editores). - Ejecuta los watchers en un hilo o proceso dedicado para que un handler lento no pueda frenar la entrega de eventos. Si vas a procesar archivos grandes o streams en el handler, pasa la ruta a una cola y vuelve de inmediato.
Errores Comunes
- Asumir que
modifyse dispara una sola vez por guardado: los editores pueden disparar muchos eventos, o secuencias de escritura atómica. - No limpiar los watchers al cerrar, lo que fuga descriptores de archivo y eventualmente llega al
max_user_watches. - Observar unidades de red con APIs nativas que no pueden ver cambios remotos.
- Ignorar los eventos de
rename/moved— llegan como create + delete separados en algunos SO. - Procesar un archivo apenas dispara
create, antes de que el escritor lo haya cerrado. Espera a que paren losmodify, o usaawaitWriteFinishde chokidar.
Soluciones Avanzadas
Python: watcher con debounce y coalescencia de eventos
Los guardados rápidos del editor colapsan en un solo callback después de un período de quietud. El filtro por extensión ocurre antes del debounce, así que los temporales ni siquiera agendan un timer.
import time
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from typing import Callable
class DebouncedEventHandler(FileSystemEventHandler):
"""Coalescencia de eventos rápidos en un solo callback tras un período de quietud."""
def __init__(self, callback: Callable[[str, str], None],
debounce_seconds: float = 0.3,
extensions: list[str] | None = None):
self.callback = callback
self.debounce = debounce_seconds
self.extensions = extensions or []
self._pending: dict[str, dict] = {}
self._lock = threading.Lock()
self._timer: threading.Timer | None = None
def _should_process(self, path: str) -> bool:
if not self.extensions:
return True
return any(path.endswith(ext) for ext in self.extensions)
def _on_event(self, event_type: str, src_path: str):
if not self._should_process(src_path):
return
with self._lock:
self._pending[src_path] = {
"type": event_type,
"time": time.time(),
}
if self._timer:
self._timer.cancel()
self._timer = threading.Timer(self.debounce, self._flush)
self._timer.start()
def _flush(self):
with self._lock:
for path, info in self._pending.items():
self.callback(info["type"], path)
self._pending.clear()
def on_created(self, event):
if not event.is_directory:
self._on_event("created", event.src_path)
def on_modified(self, event):
if not event.is_directory:
self._on_event("modified", event.src_path)
def on_deleted(self, event):
if not event.is_directory:
self._on_event("deleted", event.src_path)
def on_moved(self, event):
if not event.is_directory:
self._on_event("moved", event.dest_path)
def handle_change(event_type: str, path: str):
print(f"[{event_type}] {path}")
observer = Observer()
handler = DebouncedEventHandler(
callback=handle_change,
debounce_seconds=0.3,
extensions=[".py", ".js", ".json", ".yaml"],
)
observer.schedule(handler, path="./src", recursive=True)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
JavaScript: chokidar con filtros y detección de escritura estable
Chokidar normaliza las rarezas que fs.watch te deja resolver — incluido awaitWriteFinish, que espera a que un archivo deje de cambiar antes de reportarlo. Solo eso elimina la mayoría de los bugs de “leer un archivo a medio escribir”.
const chokidar = require('chokidar');
const watcher = chokidar.watch('./src', {
ignored: /(^|[\/\\])\./, // ignorar dotfiles
persistent: true,
ignoreInitial: true,
followSymlinks: false,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
});
const debounce = new Map();
function debouncedRun(file, fn, delay = 300) {
if (debounce.has(file)) clearTimeout(debounce.get(file));
debounce.set(file, setTimeout(() => {
fn(file);
debounce.delete(file);
}, delay));
}
watcher
.on('add', file => debouncedRun(file, f => {
if (f.endsWith('.csv')) processCSV(f);
}))
.on('change', file => debouncedRun(file, f => {
if (f.endsWith('.js')) rebuildBundle(f);
if (f.endsWith('.css')) recompileStyles(f);
}))
.on('unlink', file => {
console.log(`Eliminado: ${file}`);
cleanupCache(file);
})
.on('error', err => console.error('Error del watcher:', err))
.on('ready', () => console.log('Escaneo inicial completo. Observando cambios...'));
function processCSV(file) { console.log(`Procesando CSV: ${file}`); }
function rebuildBundle(file) { console.log(`Reconstruyendo: ${file}`); }
function recompileStyles(file) { console.log(`Recompilando CSS: ${file}`); }
function cleanupCache(file) { console.log(`Limpiando caché para: ${file}`); }
// Limpieza al salir
process.on('SIGINT', () => watcher.close().then(() => process.exit(0)));
Java: watch recursivo con thread pool
WatchService solo observa los directorios que registras, así que la recursión implica recorrer el árbol al inicio y registrar los directorios nuevos a medida que se crean. La verificación de OVERFLOW no es opcional: bajo carga el kernel descarta eventos, y esa es tu señal para re-escanear.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
public class RecursiveWatcher {
private final WatchService watchService;
private final ExecutorService executor;
private final AtomicBoolean running = new AtomicBoolean(true);
private final ConcurrentHashMap<WatchKey, Path> keys = new ConcurrentHashMap<>();
public RecursiveWatcher() throws Exception {
this.watchService = FileSystems.getDefault().newWatchService();
this.executor = Executors.newSingleThreadExecutor();
}
public void registerAll(Path start) throws Exception {
Files.walkFileTree(start, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
WatchKey key = dir.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
keys.put(key, dir);
return FileVisitResult.CONTINUE;
}
});
}
public void start() {
executor.submit(() -> {
while (running.get()) {
try {
WatchKey key = watchService.poll(1, TimeUnit.SECONDS);
if (key == null) continue;
Path dir = keys.get(key);
if (dir == null) {
key.reset();
continue;
}
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
continue; // eventos perdidos; re-escanear dir si importa la exactitud
}
Path fullPath = dir.resolve((Path) event.context());
System.out.println(event.kind() + ": " + fullPath);
// Auto-registrar nuevos subdirectorios
if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE
&& Files.isDirectory(fullPath)) {
registerAll(fullPath);
}
}
if (!key.reset()) {
keys.remove(key); // directorio ya no accesible
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
public void stop() throws Exception {
running.set(false);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
watchService.close();
}
}
// Uso
// RecursiveWatcher watcher = new RecursiveWatcher();
// watcher.registerAll(Path.of("./src"));
// watcher.start();
// Runtime.getRuntime().addShutdownHook(new Thread(watcher::stop));
Bash: inotifywait para observación simple
Solo Linux, sin bibliotecas — inotify-tools expone inotify directamente. El debounce de abajo es un enfoque simplificado para una sola máquina; para cualquier cosa concurrente usa un lock real o un watcher de verdad.
#!/usr/bin/env bash
set -euo pipefail
# Observar un directorio con inotifywait (solo Linux)
# Requiere: apt install inotify-tools
WATCH_DIR="${1:-./watched}"
DEBOUNCE_SECONDS=0.3
echo "Observando: $WATCH_DIR"
inotifywait -m -r --format '%w%f|%e' \
-e create,modify,delete,move \
--exclude '\.(swp|tmp|log)' \
"$WATCH_DIR" | while IFS='|' read -r file event; do
# Debounce: saltar si mismo archivo+evento visto recientemente (simplificado)
CACHE_KEY="${event}:${file}"
if [[ -f /tmp/.watch_cache ]] && grep -q "^${CACHE_KEY}$" /tmp/.watch_cache 2>/dev/null; then
continue
fi
echo "${CACHE_KEY}" >> /tmp/.watch_cache
sleep "$DEBOUNCE_SECONDS"
sed -i "/^${CACHE_KEY//\//\\/}$/d" /tmp/.watch_cache 2>/dev/null || true
echo "[$(date +%H:%M:%S)] $event: $file"
# Disparar acción según extensión
case "$file" in
*.py) echo " -> Archivo Python cambiado, ejecutando lint..." ;;
*.js) echo " -> Archivo JS cambiado, reconstruyendo bundle..." ;;
*.csv) echo " -> Archivo CSV añadido, procesando..." ;;
esac
done
Código companion: El repo companion de watch-file-changes tiene los cuatro ejemplos avanzados como archivos ejecutables.
Preguntas frecuentes
¿Puedo observar rutas remotas o de red?
Los watchers nativos generalmente no pueden — NFS y SMB no propagan eventos del kernel entre máquinas. Usa polling: usePolling: true de chokidar, o FileAlterationMonitor de Apache Commons IO del lado Java.
¿Por qué recibo eventos duplicados?
Muchos editores escriben archivos atómicamente — crear temporal, escribir, renombrar — lo que produce una ráfaga de eventos create/modify/delete por un solo guardado. Aplica debounce con un retraso corto (100–300 ms) o usa awaitWriteFinish de chokidar.
¿Cuántos archivos puedo observar a la vez?
Depende del límite del SO, no solo del conteo de archivos. inotify de Linux limita los watches por usuario con fs.inotify.max_user_watches (subilo con sysctl si hace falta). FSEvents de macOS escala mucho más. Los watchers recursivos sobre árboles enormes son la forma habitual de llegar al techo.
¿Funciona fs.watch dentro de Docker?
A menudo no como esperarías. Los bind mounts no reenvían confiablemente los eventos del sistema de archivos del host al kernel del contenedor, así que el watcher queda mudo. Ejecuta el watcher en el host o cambiá a polling dentro del contenedor.
Python: ¿watchdog o watchfiles?
Ambas envuelven las mismas APIs del kernel. watchdog es la opción más veterana y probada, con una API de handlers más rica. watchfiles tiene núcleo en Rust y es amigable con async — es lo que usa uvicorn --reload — así que preferila en apps asyncio o cuando importe la velocidad de escaneo.
Recursos Relacionados
Leer Archivos Grandes
Cómo leer archivos grandes de forma eficiente sin agotar la memoria.
RecipeEscribir Archivos Grandes
Cómo escribir archivos grandes de forma eficiente usando salida bufferizada y streaming.
RecipeValidación de Subida de Archivos
Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.
RecipeProcesar Archivos Grandes con Streams
Cómo leer, transformar y escribir archivos grandes eficientemente usando streams sin cargar archivos completos en memoria en Python, Node.js y Java.
RecipeComprimir y Descomprimir Archivos con ZIP, GZIP y TAR
Crea y extrae archivos ZIP, GZIP y TAR en Python, Node.js, Java y Bash con lectura en streaming y patrones de extracción segura.
RecipeCómo Copiar y Mover Archivos con Python, JS, Java y Bash
Aprende a copiar y mover archivos multiplataforma con Python, JavaScript, Java y Bash. Incluye movimientos atómicos, checksums, symlinks y patrones batch.