/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package gobblin.util.filesystem;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Map;
import org.apache.commons.io.IOCase;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.PathFilter;
import com.google.common.collect.Maps;
import gobblin.util.DecoratorUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class PathAlterationObserver {
private final Map<PathAlterationListener, PathAlterationListener> listeners = Maps.newConcurrentMap();
private final FileStatusEntry rootEntry;
private final PathFilter pathFilter;
private final Comparator<Path> comparator;
private final FileSystem fs;
private final Path[] EMPTY_PATH_ARRAY = new Path[0];
/**
* Final processing.
*/
public void destroy() {
}
/**
* Construct an observer for the specified directory.
*
* @param directoryName the name of the directory to observe
*/
public PathAlterationObserver(final String directoryName)
throws IOException {
this(new Path(directoryName));
}
/**
* Construct an observer for the specified directory and file filter.
*
* @param directoryName the name of the directory to observe
* @param pathFilter The file filter or null if none
*/
public PathAlterationObserver(final String directoryName, final PathFilter pathFilter)
throws IOException {
this(new Path(directoryName), pathFilter);
}
/**
* Construct an observer for the specified directory.
*
* @param directory the directory to observe
*/
public PathAlterationObserver(final Path directory)
throws IOException {
this(directory, null);
}
/**
* Construct an observer for the specified directory and file filter.
*
* @param directory the directory to observe
* @param pathFilter The file filter or null if none
*/
public PathAlterationObserver(final Path directory, final PathFilter pathFilter)
throws IOException {
this(new FileStatusEntry(directory), pathFilter);
}
/**
* The comparison between path is always case-sensitive in this general file system context.
*/
public PathAlterationObserver(final FileStatusEntry rootEntry, final PathFilter pathFilter)
throws IOException {
if (rootEntry == null) {
throw new IllegalArgumentException("Root entry is missing");
}
if (rootEntry.getPath() == null) {
throw new IllegalArgumentException("Root directory is missing");
}
this.rootEntry = rootEntry;
this.pathFilter = pathFilter;
this.fs = rootEntry.getPath().getFileSystem(new Configuration());
// By default, the comparsion is case sensitive.
this.comparator = new Comparator<Path>() {
@Override
public int compare(Path o1, Path o2) {
return IOCase.SENSITIVE.checkCompareTo(o1.toUri().toString(), o2.toUri().toString());
}
};
}
/**
* Add a file system listener.
*
* @param listener The file system listener
*/
public void addListener(final PathAlterationListener listener) {
if (listener != null) {
this.listeners.put(listener, new ExceptionCatchingPathAlterationListenerDecorator(listener));
}
}
/**
* Remove a file system listener.
*
* @param listener The file system listener
*/
public void removeListener(final PathAlterationListener listener) {
if (listener != null) {
this.listeners.remove(listener);
}
}
/**
* Returns the set of registered file system listeners.
*
* @return The file system listeners
*/
public Iterable<PathAlterationListener> getListeners() {
return listeners.keySet();
}
/**
* Initialize the observer.
* @throws IOException if an error occurs
*/
public void initialize() throws IOException {
rootEntry.refresh(rootEntry.getPath());
final FileStatusEntry[] children = doListPathsEntry(rootEntry.getPath(), rootEntry);
rootEntry.setChildren(children);
}
/**
* Check whether the file and its chlidren have been created, modified or deleted.
*/
public void checkAndNotify()
throws IOException {
/* fire onStart() */
for (final PathAlterationListener listener : listeners.values()) {
listener.onStart(this);
}
/* fire directory/file events */
final Path rootPath = rootEntry.getPath();
if (fs.exists(rootPath)) {
// Current existed.
checkAndNotify(rootEntry, rootEntry.getChildren(), listPaths(rootPath));
} else if (rootEntry.isExists()) {
// Existed before and not existed now.
checkAndNotify(rootEntry, rootEntry.getChildren(), EMPTY_PATH_ARRAY);
} else {
// Didn't exist and still doesn't
}
/* fire onStop() */
for (final PathAlterationListener listener : listeners.values()) {
listener.onStop(this);
}
}
/**
* Compare two file lists for files which have been created, modified or deleted.
*
* @param parent The parent entry
* @param previous The original list of paths
* @param currentPaths The current list of paths
*/
private void checkAndNotify(final FileStatusEntry parent, final FileStatusEntry[] previous, final Path[] currentPaths)
throws IOException {
int c = 0;
final FileStatusEntry[] current =
currentPaths.length > 0 ? new FileStatusEntry[currentPaths.length] : FileStatusEntry.EMPTY_ENTRIES;
for (final FileStatusEntry previousEntry : previous) {
while (c < currentPaths.length && comparator.compare(previousEntry.getPath(), currentPaths[c]) > 0) {
current[c] = createPathEntry(parent, currentPaths[c]);
doCreate(current[c]);
c++;
}
if (c < currentPaths.length && comparator.compare(previousEntry.getPath(), currentPaths[c]) == 0) {
doMatch(previousEntry, currentPaths[c]);
checkAndNotify(previousEntry, previousEntry.getChildren(), listPaths(currentPaths[c]));
current[c] = previousEntry;
c++;
} else {
checkAndNotify(previousEntry, previousEntry.getChildren(), EMPTY_PATH_ARRAY);
doDelete(previousEntry);
}
}
for (; c < currentPaths.length; c++) {
current[c] = createPathEntry(parent, currentPaths[c]);
doCreate(current[c]);
}
parent.setChildren(current);
}
/**
* Create a new FileStatusEntry for the specified file.
*
* @param parent The parent file entry
* @param childPath The file to create an entry for
* @return A new file entry
*/
private FileStatusEntry createPathEntry(final FileStatusEntry parent, final Path childPath)
throws IOException {
final FileStatusEntry entry = parent.newChildInstance(childPath);
entry.refresh(childPath);
final FileStatusEntry[] children = doListPathsEntry(childPath, entry);
entry.setChildren(children);
return entry;
}
/**
* List the path in the format of FileStatusEntry array
* @param path The path to list files for
* @param entry the parent entry
* @return The child files
*/
private FileStatusEntry[] doListPathsEntry(Path path, FileStatusEntry entry)
throws IOException {
final Path[] paths = listPaths(path);
final FileStatusEntry[] children =
paths.length > 0 ? new FileStatusEntry[paths.length] : FileStatusEntry.EMPTY_ENTRIES;
for (int i = 0; i < paths.length; i++) {
children[i] = createPathEntry(entry, paths[i]);
}
return children;
}
/**
* Fire directory/file created events to the registered listeners.
*
* @param entry The file entry
*/
private void doCreate(final FileStatusEntry entry) {
for (final PathAlterationListener listener : listeners.values()) {
if (entry.isDirectory()) {
listener.onDirectoryCreate(entry.getPath());
} else {
listener.onFileCreate(entry.getPath());
}
}
final FileStatusEntry[] children = entry.getChildren();
for (final FileStatusEntry aChildren : children) {
doCreate(aChildren);
}
}
/**
* Fire directory/file change events to the registered listeners.
*
* @param entry The previous file system entry
* @param path The current file
*/
private void doMatch(final FileStatusEntry entry, final Path path)
throws IOException {
if (entry.refresh(path)) {
for (final PathAlterationListener listener : listeners.values()) {
if (entry.isDirectory()) {
listener.onDirectoryChange(path);
} else {
listener.onFileChange(path);
}
}
}
}
/**
* Fire directory/file delete events to the registered listeners.
*
* @param entry The file entry
*/
private void doDelete(final FileStatusEntry entry) {
for (final PathAlterationListener listener : listeners.values()) {
if (entry.isDirectory()) {
listener.onDirectoryDelete(entry.getPath());
} else {
listener.onFileDelete(entry.getPath());
}
}
}
/**
* List the contents of a directory denoted by Path
*
* @param path The path(File Object in general file system) to list the contents of
* @return the directory contents or a zero length array if
* the empty or the file is not a directory
*/
private Path[] listPaths(final Path path)
throws IOException {
Path[] children = null;
ArrayList<Path> tmpChildrenPath = new ArrayList<>();
if (fs.isDirectory(path)) {
// Get children's path list.
FileStatus[] chiledrenFileStatus = pathFilter == null ? fs.listStatus(path) : fs.listStatus(path, pathFilter);
for (FileStatus childFileStatus : chiledrenFileStatus) {
tmpChildrenPath.add(childFileStatus.getPath());
}
children = tmpChildrenPath.toArray(new Path[tmpChildrenPath.size()]);
}
if (children == null) {
children = EMPTY_PATH_ARRAY;
}
if (comparator != null && children.length > 1) {
Arrays.sort(children, comparator);
}
return children;
}
}