package tc.oc.file; import java.io.IOException; import java.nio.file.FileSystem; 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.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.logging.Logger; import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Singleton; import com.google.common.cache.LoadingCache; import com.google.common.collect.HashMultimap; import com.google.common.collect.SetMultimap; import com.google.common.util.concurrent.AbstractExecutionThreadService; import com.google.common.util.concurrent.MoreExecutors; import tc.oc.commons.core.exception.ExceptionHandler; import tc.oc.commons.core.logging.Loggers; import tc.oc.commons.core.plugin.PluginFacet; import tc.oc.commons.core.util.CacheUtils; import static java.nio.file.StandardWatchEventKinds.*; /** * A service that watches files for changes and notifies a {@link PathWatcher}. */ @Singleton public class PathWatcherServiceImpl implements PluginFacet, PathWatcherService { private final Logger logger; private final ExceptionHandler exceptionHandler; private final LoadingCache<FileSystem, WatchedFileSystem> fileSystems; private final LoadingCache<Path, WatchedFileSystem.WatchedDirectory> watchedDirs; @Inject private PathWatcherServiceImpl(Loggers loggers, ExceptionHandler exceptionHandler) { this.logger = loggers.get(getClass()); this.exceptionHandler = exceptionHandler; this.fileSystems = CacheUtils.newCache(WatchedFileSystem::new); this.watchedDirs = CacheUtils.newCache( dir -> fileSystems.getUnchecked(dir.getFileSystem()).new WatchedDirectory(dir) ); } @Override public void disable() { fileSystems.asMap().values().forEach(w -> w.stopAsync().awaitTerminated()); } @Override public PathWatcherHandle watch(Path path, Executor executor, PathWatcher callback) throws IOException { path = path.toAbsolutePath(); if(path.getNameCount() == 0) { throw new IllegalArgumentException("Cannot watch the root directory"); } return watchedDirs.getUnchecked(path.getParent()).new WatchedPath(path, executor, callback); } private class WatchedFileSystem extends AbstractExecutionThreadService { final FileSystem fileSystem; final WatchService watchService; final Map<WatchKey, WatchedDirectory> dirsByKey = new ConcurrentHashMap<>(); @Nullable Thread thread; WatchedFileSystem(FileSystem fileSystem) throws IOException { logger.fine(() -> "Watching new file system " + fileSystem); this.fileSystem = fileSystem; this.watchService = this.fileSystem.newWatchService(); startAsync(); } @Override protected void triggerShutdown() { if(thread != null) { thread.interrupt(); } } @Override protected void startUp() throws Exception { thread = Thread.currentThread(); } @Override protected void shutDown() throws Exception { dirsByKey.keySet().forEach(WatchKey::cancel); } @Override protected void run() { while(isRunning()) { try { final WatchKey key = watchService.take(); final WatchedDirectory watchedDirectory = dirsByKey.get(key); if(watchedDirectory == null) { logger.warning("Cancelling unknown key " + key); key.cancel(); } else { for(WatchEvent<?> event : key.pollEvents()) { watchedDirectory.dispatch((WatchEvent<Path>) event); } key.reset(); } } catch(InterruptedException e) { // ignore, just check for termination } } } class WatchedDirectory implements PathWatcher { final Path dir; final SetMultimap<Path, WatchedPath> watchedPaths = HashMultimap.create(); @Nullable WatchKey key; WatchedDirectory(Path dir) throws IOException { logger.fine(() -> "Watching new directory " + dir); this.dir = dir; // Watch ourselves, unless we are the root dir if(dir.getParent() != null) { watch(dir, MoreExecutors.sameThreadExecutor(), this); } } void enable() { if(key == null || !key.isValid()) { logger.fine(() -> "Enabling watched directory " + dir); exceptionHandler.run(() -> { key = this.dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); dirsByKey.put(key, this); }); } } void disable() { if(key != null) { logger.fine(() -> "Disabling watched directory " + dir); dirsByKey.remove(key); key.cancel(); } } void cancel() { logger.fine(() -> "Cancelling watched directory " + dir); disable(); watchedDirs.invalidate(dir); } @Override public void fileCreated(Path path) { enable(); } @Override public void fileDeleted(Path path) { disable(); } void dispatch(WatchEvent<Path> event) { for(WatchedPath watchedPath : watchedPaths.get(dir.resolve(event.context()))) { watchedPath.dispatch(event.kind()); } } class WatchedPath implements PathWatcherHandle { final Path path; final PathWatcher callback; final Executor executor; public WatchedPath(Path path, Executor executor, PathWatcher callback) { logger.fine(() -> "Watching new path " + path); this.path = path; this.executor = executor; this.callback = callback; watchedPaths.put(path, this); dispatch(Files.exists(path) ? ENTRY_CREATE : ENTRY_DELETE); } @Override public void cancel() { logger.fine(() -> "Cancelling watched path " + path); watchedPaths.remove(path, this); if(watchedPaths.isEmpty()) { WatchedDirectory.this.cancel(); } } void dispatch(WatchEvent.Kind<Path> kind) { logger.fine(() -> "Dispatching event " + kind + " for path " + path); exceptionHandler.run(() -> executor.execute(() -> { if(ENTRY_CREATE.equals(kind)) { callback.fileCreated(path); } else if(ENTRY_MODIFY.equals(kind)) { callback.fileModified(path); } else if(ENTRY_DELETE.equals(kind)) { callback.fileDeleted(path); } })); } } } } }