StackPractices
intermediate Por Mathias Paulenko

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_watches de 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.

Flujo de un evento de archivo. Un archivo se crea, modifica o elimina en disco. El kernel del SO envía el evento a la biblioteca watcher, que aplica debounce y filtros de extensión, y finalmente invoca tu manejador para disparar una acción como una recompilación.

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_OVERFLOW y Java entrega OVERFLOW. 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: chokidar lo soporta con usePolling: true y Java ofrece FileAlterationMonitor.

Variantes

TecnologíaEnfoqueNotas
PythonwatchdogMultiplataforma, maneja casos límite como renombres rápidos
PythonwatchfilesNúcleo en Rust (notify-rs), amigable con async; es lo que usa uvicorn --reload
JavaScriptchokidarMás confiable que fs.watch; normaliza eventos y soporta polling
JavaApache Commons IO FileAlterationMonitorFallback por polling para JDKs antiguos o unidades de red
Linux/Bashinotifywaitinotify crudo desde scripts de shell; ver el ejemplo avanzado

Lo que funciona

  1. Aplica debounce a eventos rápidos — los editores y las herramientas de build suelen disparar varias escrituras por cada guardado lógico.
  2. Maneja siempre el evento error, OVERFLOW y las excepciones de WatchService; un watcher silencioso es peor que no tener watcher.
  3. Usa watchers recursivos con moderación; cada directorio cuesta un handle.
  4. Filtra por extensión para ignorar archivos temporales (.tmp, .swp, lock files de editores).
  5. 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

  1. Asumir que modify se dispara una sola vez por guardado: los editores pueden disparar muchos eventos, o secuencias de escritura atómica.
  2. No limpiar los watchers al cerrar, lo que fuga descriptores de archivo y eventualmente llega al max_user_watches.
  3. Observar unidades de red con APIs nativas que no pueden ver cambios remotos.
  4. Ignorar los eventos de rename/moved — llegan como create + delete separados en algunos SO.
  5. Procesar un archivo apenas dispara create, antes de que el escritor lo haya cerrado. Espera a que paren los modify, o usa awaitWriteFinish de 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.