All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.seeq.utilities.FileWatcher Maven / Gradle / Ivy

The newest version!
package com.seeq.utilities;

import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static java.nio.file.StandardWatchEventKinds.OVERFLOW;

import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.time.Duration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nullable;

import com.seeq.utilities.process.OperatingSystem;

import lombok.extern.slf4j.Slf4j;

/**
 * Monitors a directory for changes to a specific file and notifies the listener of any changes to the file.
 */
@Slf4j
public class FileWatcher {
    private final Path directory;
    private final String file;
    private final FileChangeListener listener;
    private final Duration debouncePeriod;
    private Thread runnerThread;
    private final AutoResetEvent readyEvent = new AutoResetEvent(false);
    private final Object lockObj = new Object();
    private Map checksumOfFilesInDirectory;
    private Duration nextPollInterval;

    /**
     * Constructor for the file watcher. Note that {@link #start()} must be called to begin watching for changes to the
     * directory.
     *
     * @param directory
     *         The directory to watch for changes. Created if it does not exist.
     * @param file
     *         The name of the file to watch for changes. If null, the whole directory is monitored for changes.
     * @param listener
     *         The listener that is invoked upon the file being changed.
     * @param debouncePeriod
     *         The period to wait before notifying the listener. If an additional change happens during this period,
     *         then the debouncePeriod clock is reset and another debouncePeriod must elapse before the listener is
     *         notified.
     */
    public FileWatcher(Path directory, @Nullable String file, FileChangeListener listener, Duration debouncePeriod) {
        this.directory = directory;
        this.file = file;
        this.listener = listener;
        this.runnerThread = null;
        this.debouncePeriod = debouncePeriod;
    }

    /**
     * Start watching the API key file by spawning a new thread.
     *
     * @throws IOException
     *         Thrown when the directory cannot be created or watched.
     */
    public void start() throws IOException {
        synchronized (this.lockObj) {
            if (this.runnerThread != null) {
                return;
            }

            Files.createDirectories(this.directory);

            try (Stream filesToWatch = Files.list(this.directory)) {
                Stream filteredFiles = filesToWatch.filter(
                        f -> this.file == null || this.file.equals(f.toFile().getName())
                );
                this.checksumOfFilesInDirectory = this.createChecksumOfFiles(filteredFiles);
            }

            this.runnerThread = new Thread(() -> this.run());
            this.runnerThread.setName(String.format("FileWatcher: %s",
                    this.directory.getFileName() + (this.file != null ? "/" + this.file : "")));
            this.runnerThread.setDaemon(true);
            this.runnerThread.start();
            try {
                if (this.readyEvent.waitOne(Constants.GENERAL_TIMEOUT) == false) {
                    throw new IOException("Could not start FileWatcher");
                }
            } catch (InterruptedException e) {
                throw new IOException("FileWatcher startup interrupted");
            }

            // OSX, and specifically the HFS+ filesystem, only records modification times on files to the nearest
            // second. That can lead to a bug where a file is created, the watcher started, and the same file modified
            // within a second without any change event being fired. By sleeping at least one second we can fix
            // this shortcoming.
            if (OperatingSystem.isMac()) {
                try {
                    Thread.sleep(1100);
                } catch (InterruptedException ignored) {}
            }
        }
    }

    /**
     * Creates Checksum for a stream of paths (relative to main directory)
     *
     * @param filesInDirectory
     *         the files
     * @return the map of file checksum
     */
    private Map createChecksumOfFiles(Stream filesInDirectory) {
        return filesInDirectory
                .filter(f -> f.toFile().isFile())
                .collect(Collectors.toMap(path -> path.toFile().getName(), path -> {
                    try {
                        return new Checksum(path);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }));
    }

    /**
     * Stop this thread. The killing happens lazily, giving the running thread an opportunity to finish the work at
     * hand.
     */
    public void stop() {
        synchronized (this.lockObj) {
            if (this.runnerThread == null) {
                return;
            }

            this.runnerThread.interrupt();
            try {
                this.runnerThread.join();
            } catch (InterruptedException e) {
                // Do nothing, we are likely shutting down anyway
            }
            this.runnerThread = null;
        }
    }

    /**
     * Determines whether or not the watcher service is running.
     *
     * @return True if the watcher is running, false otherwise
     */
    public boolean isRunning() {
        synchronized (this.lockObj) {
            return this.runnerThread != null;
        }
    }

    private final Map changeDetectionTimeByFile = new HashMap<>();

    /**
     * Creates a watch service on the file and invokes the listeners when the watched file changes.
     */
    private void run() {
        LOG.debug("FileWatcher started");
        try (WatchService watcher = this.getWatcherService()) {
            if (OperatingSystem.isMac()) {
                ((PollingWatchServiceForMacOs) watcher).register(this.directory,
                        new WatchEvent.Kind[] { ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE });
            } else {
                this.directory.register(watcher, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
            }
            this.readyEvent.set();

            while (!Thread.currentThread().isInterrupted()) {
                try {
                    this.handleFileChanges();

                    WatchKey key = watcher.poll(this.nextPollInterval.toNanos(), TimeUnit.NANOSECONDS);

                    if (key == null) {
                        continue;
                    }

                    for (WatchEvent event : key.pollEvents()) {
                        WatchEvent.Kind eventKind = event.kind();

                        @SuppressWarnings("unchecked")
                        Path eventPath = ((WatchEvent) event).context();

                        // Overflow occurs when the watch event queue overflows with events.
                        if (eventKind.equals(OVERFLOW)) {
                            continue;
                        }

                        if (this.file == null) {
                            this.changeDetectionTimeByFile.put(eventPath.toString(), System.nanoTime());
                        } else {
                            if (eventPath.toString().equals(this.file)) {
                                this.changeDetectionTimeByFile.put(eventPath.toString(), System.nanoTime());
                            }
                        }
                    }

                    // Reset key to allow further events for this key to be processed.
                    boolean valid = key.reset();
                    if (!valid) {
                        break;
                    }
                } catch (InterruptedException e) {
                    LOG.debug("FileWatcher interrupted");
                    return;
                } catch (RuntimeException e) {
                    LOG.error("FileWatcher encountered exception", e);
                }
            }
        } catch (IOException e) {
            LOG.error("FileWatcher encountered exception", e);
        }

        // Depending on how the FileWatcher stopped, we may have remaining file changes that need to be handled
        this.handleFileChanges();

        LOG.debug("FileWatcher stopped");
    }

    private WatchService getWatcherService() throws IOException {
        if (OperatingSystem.isMac()) {
            // Use a custom version of PollingWatchService because Java's default on MacOS is a 10-second polling loop.
            // @see http://stackoverflow.com/a/18362404/1108708
            return new PollingWatchServiceForMacOs();
        } else {
            return FileSystems.getDefault().newWatchService();
        }
    }

    private void handleFileChanges() {
        Set processedFiles = new HashSet<>();
        this.nextPollInterval = Duration.ofNanos(Long.MAX_VALUE);
        this.changeDetectionTimeByFile.forEach((touchedFile, changeWasDetectedAt) -> {
            Duration elapsed = Duration.ofNanos(System.nanoTime() - changeWasDetectedAt);
            Duration remaining = this.debouncePeriod.minus(elapsed);
            if (remaining.isZero() || remaining.isNegative()) {
                Checksum oldChecksum = this.checksumOfFilesInDirectory.get(touchedFile);
                Path fullPath = this.directory.resolve(touchedFile);
                boolean isDirectory = fullPath.toFile().isDirectory();
                Checksum newChecksum = oldChecksum;
                if (!isDirectory) {
                    try {
                        newChecksum = new Checksum(fullPath);
                    } catch (IOException ioe) {
                        return;
                    }
                    this.checksumOfFilesInDirectory.put(touchedFile, newChecksum);
                }

                processedFiles.add(touchedFile);

                // Compare checksums, because it has proven possible for the filesystem to notify us of a
                // change even though the file is unchanged. This was seen at Tesla, and was causing
                // continual metadata syncs.
                if (isDirectory || !newChecksum.equals(oldChecksum)) {
                    // Send callback on its own thread so that the FileWatcher can be stopped from within
                    // the callback itself
                    Thread callbackThread = new Thread(() -> {
                        if (Files.exists(fullPath)) {
                            this.listener.onFileModify(fullPath);
                        } else {
                            this.listener.onFileDelete(fullPath);
                        }
                    });

                    callbackThread.setName(String.format("FileWatcher callback: %s", fullPath
                            .getFileName()));
                    callbackThread.start();
                }
            } else if (remaining.compareTo(this.nextPollInterval) <= 0) {
                this.nextPollInterval = remaining;
            }
        });

        this.changeDetectionTimeByFile.keySet().removeAll(processedFiles);
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy