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