[FLINK-5345] [core] Add a utility to delete directories without failing in the presence of concurrent deletes
Project: http://git-wip-us.apache.org/repos/asf/flink/repo Commit: http://git-wip-us.apache.org/repos/asf/flink/commit/b1be3f5c Tree: http://git-wip-us.apache.org/repos/asf/flink/tree/b1be3f5c Diff: http://git-wip-us.apache.org/repos/asf/flink/diff/b1be3f5c Branch: refs/heads/release-1.2 Commit: b1be3f5c3c9e7410d92c74422b10a6efb42fd4d5 Parents: 6b3c683 Author: Stephan Ewen <se...@apache.org> Authored: Wed Jan 11 21:05:57 2017 +0100 Committer: Stephan Ewen <se...@apache.org> Committed: Mon Jan 16 15:37:48 2017 +0100 ---------------------------------------------------------------------- .../java/org/apache/flink/util/FileUtils.java | 171 +++++++++++++++++-- .../org/apache/flink/util/FileUtilsTest.java | 162 ++++++++++++++++++ 2 files changed, 319 insertions(+), 14 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/flink/blob/b1be3f5c/flink-core/src/main/java/org/apache/flink/util/FileUtils.java ---------------------------------------------------------------------- diff --git a/flink-core/src/main/java/org/apache/flink/util/FileUtils.java b/flink-core/src/main/java/org/apache/flink/util/FileUtils.java index 23f5eb9..0d527d5 100644 --- a/flink-core/src/main/java/org/apache/flink/util/FileUtils.java +++ b/flink-core/src/main/java/org/apache/flink/util/FileUtils.java @@ -23,27 +23,28 @@ import org.apache.flink.core.fs.FileSystem; import org.apache.flink.core.fs.Path; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.StandardOpenOption; +import static org.apache.flink.util.Preconditions.checkNotNull; + /** - * This is a utility class to deal with temporary files. + * This is a utility class to deal files and directories. Contains utilities for recursive + * deletion and creation of temporary files. */ public final class FileUtils { - /** - * The alphabet to construct the random part of the filename from. - */ - private static final char[] ALPHABET = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', - 'e', 'f' }; + /** The alphabet to construct the random part of the filename from. */ + private static final char[] ALPHABET = + { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f' }; - /** - * The length of the random part of the filename. - */ - private static final int LENGTH = 12; + /** The length of the random part of the filename. */ + private static final int RANDOM_FILE_NAME_LENGTH = 12; - + // ------------------------------------------------------------------------ /** * Constructs a random filename with the given prefix and @@ -54,10 +55,9 @@ public final class FileUtils { * @return the generated random filename with the given prefix */ public static String getRandomFilename(final String prefix) { - final StringBuilder stringBuilder = new StringBuilder(prefix); - for (int i = 0; i < LENGTH; i++) { + for (int i = 0; i < RANDOM_FILE_NAME_LENGTH; i++) { stringBuilder.append(ALPHABET[(int) Math.floor(Math.random() * (double) ALPHABET.length)]); } @@ -87,7 +87,150 @@ public final class FileUtils { } // ------------------------------------------------------------------------ - // Deleting directories + // Deleting directories on standard File Systems + // ------------------------------------------------------------------------ + + /** + * Removes the given file or directory recursively. + * + * <p>If the file or directory does not exist, this does not throw an exception, but simply does nothing. + * It considers the fact that a file-to-be-deleted is not present a success. + * + * <p>This method is safe against other concurrent deletion attempts. + * + * @param file The file or directory to delete. + * + * @throws IOException Thrown if the directory could not be cleaned for some reason, for example + * due to missing access/write permissions. + */ + public static void deleteFileOrDirectory(File file) throws IOException { + checkNotNull(file, "file"); + + if (file.isDirectory()) { + // file exists and is directory + deleteDirectory(file); + } + else if (file.exists()) { + try { + Files.delete(file.toPath()); + } + catch (NoSuchFileException e) { + // if the file is already gone (concurrently), we don't mind + } + } + // else: already deleted + } + + /** + * Deletes the given directory recursively. + * + * <p>If the directory does not exist, this does not throw an exception, but simply does nothing. + * It considers the fact that a directory-to-be-deleted is not present a success. + * + * <p>This method is safe against other concurrent deletion attempts. + * + * @param directory The directory to be deleted. + * @throws IOException Thrown if the given file is not a directory, or if the directory could not be + * deleted for some reason, for example due to missing access/write permissions. + */ + public static void deleteDirectory(File directory) throws IOException { + checkNotNull(directory, "directory"); + + if (directory.isDirectory()) { + // directory exists and is a directory + + // empty the directory first + try { + cleanDirectory(directory); + } + catch (FileNotFoundException ignored) { + // someone concurrently deleted the directory, nothing to do for us + return; + } + + // delete the directory. this fails if the directory is not empty, meaning + // if new files got concurrently created. we want to fail then. + try { + Files.delete(directory.toPath()); + } + catch (NoSuchFileException ignored) { + // if someone else deleted this concurrently, we don't mind + // the result is the same for us, after all + } + } + else if (directory.exists()) { + // exists but is file, not directory + // either an error from the caller, or concurrently a file got created + throw new IOException(directory + " is not a directory"); + } + // else: does not exist, which is okay (as if deleted) + } + + /** + * Deletes the given directory recursively, not reporting any I/O exceptions + * that occur. + * + * <p>This method is identical to {@link FileUtils#deleteDirectory(File)}, except that it + * swallows all exceptions and may leave the job quietly incomplete. + * + * @param directory The directory to delete. + */ + public static void deleteDirectoryQuietly(File directory) { + if (directory == null) { + return; + } + + // delete and do not report if it fails + try { + deleteDirectory(directory); + } catch (Exception ignored) {} + } + + /** + * Removes all files contained within a directory, without removing the directory itself. + * + * <p>This method is safe against other concurrent deletion attempts. + * + * @param directory The directory to remove all files from. + * + * @throws FileNotFoundException Thrown if the directory itself does not exist. + * @throws IOException Thrown if the file indicates a proper file and not a directory, or if + * the directory could not be cleaned for some reason, for example + * due to missing access/write permissions. + */ + public static void cleanDirectory(File directory) throws IOException, FileNotFoundException { + checkNotNull(directory, "directory"); + + if (directory.isDirectory()) { + final File[] files = directory.listFiles(); + + if (files == null) { + // directory does not exist any more or no permissions + if (directory.exists()) { + throw new IOException("Failed to list contents of " + directory); + } else { + throw new FileNotFoundException(directory.toString()); + } + } + + // remove all files in the directory + for (File file : files) { + if (file != null) { + deleteFileOrDirectory(file); + } + } + } + else if (directory.exists()) { + throw new IOException(directory + " is not a directory but a regular file"); + } + else { + // else does not exist at all + throw new FileNotFoundException(directory.toString()); + } + } + + // ------------------------------------------------------------------------ + // Deleting directories on Flink FileSystem abstraction // ------------------------------------------------------------------------ /** http://git-wip-us.apache.org/repos/asf/flink/blob/b1be3f5c/flink-core/src/test/java/org/apache/flink/util/FileUtilsTest.java ---------------------------------------------------------------------- diff --git a/flink-core/src/test/java/org/apache/flink/util/FileUtilsTest.java b/flink-core/src/test/java/org/apache/flink/util/FileUtilsTest.java new file mode 100644 index 0000000..166d24d --- /dev/null +++ b/flink-core/src/test/java/org/apache/flink/util/FileUtilsTest.java @@ -0,0 +1,162 @@ +/* + * 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 org.apache.flink.util; + +import org.apache.flink.core.testutils.CheckedThread; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.util.Random; + +import static org.junit.Assert.*; +import static org.junit.Assume.*; + +public class FileUtilsTest { + + @Rule + public final TemporaryFolder tmp = new TemporaryFolder(); + + // ------------------------------------------------------------------------ + // Tests + // ------------------------------------------------------------------------ + + @Test + public void testDeleteQuietly() throws Exception { + // should ignore the call + FileUtils.deleteDirectoryQuietly(null); + + File doesNotExist = new File(tmp.getRoot(), "abc"); + FileUtils.deleteDirectoryQuietly(doesNotExist); + + File cannotDeleteParent = tmp.newFolder(); + File cannotDeleteChild = new File(cannotDeleteParent, "child"); + + try { + assumeTrue(cannotDeleteChild.createNewFile()); + assumeTrue(cannotDeleteParent.setWritable(false)); + assumeTrue(cannotDeleteChild.setWritable(false)); + + FileUtils.deleteDirectoryQuietly(cannotDeleteParent); + } + finally { + //noinspection ResultOfMethodCallIgnored + cannotDeleteParent.setWritable(true); + //noinspection ResultOfMethodCallIgnored + cannotDeleteChild.setWritable(true); + } + } + + @Test + public void testDeleteDirectory() throws Exception { + + // deleting a non-existent file should not cause an error + + File doesNotExist = new File(tmp.newFolder(), "abc"); + FileUtils.deleteDirectory(doesNotExist); + + // deleting a write protected file should throw an error + + File cannotDeleteParent = tmp.newFolder(); + File cannotDeleteChild = new File(cannotDeleteParent, "child"); + + try { + assumeTrue(cannotDeleteChild.createNewFile()); + assumeTrue(cannotDeleteParent.setWritable(false)); + assumeTrue(cannotDeleteChild.setWritable(false)); + + FileUtils.deleteDirectory(cannotDeleteParent); + fail("this should fail with an exception"); + } + catch (AccessDeniedException ignored) { + // this is expected + } + finally { + //noinspection ResultOfMethodCallIgnored + cannotDeleteParent.setWritable(true); + //noinspection ResultOfMethodCallIgnored + cannotDeleteChild.setWritable(true); + } + } + + @Test + public void testDeleteDirectoryConcurrently() throws Exception { + final File parent = tmp.newFolder(); + + generateRandomDirs(parent, 20, 5, 3); + + // start three concurrent threads that delete the contents + CheckedThread t1 = new Deleter(parent); + CheckedThread t2 = new Deleter(parent); + CheckedThread t3 = new Deleter(parent); + t1.start(); + t2.start(); + t3.start(); + t1.sync(); + t2.sync(); + t3.sync(); + + // assert is empty + assertFalse(parent.exists()); + } + + // ------------------------------------------------------------------------ + // Utilities + // ------------------------------------------------------------------------ + + private static void generateRandomDirs(File dir, int numFiles, int numDirs, int depth) throws IOException { + // generate the random files + for (int i = 0; i < numFiles; i++) { + File file = new File(dir, new AbstractID().toString()); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(1); + } + } + + if (depth > 0) { + // generate the directories + for (int i = 0; i < numDirs; i++) { + File subdir = new File(dir, new AbstractID().toString()); + assertTrue(subdir.mkdir()); + generateRandomDirs(subdir, numFiles, numDirs, depth - 1); + } + } + } + + // ------------------------------------------------------------------------ + + private static class Deleter extends CheckedThread { + + private final File target; + + Deleter(File target) { + this.target = target; + } + + @Override + public void go() throws Exception { + FileUtils.deleteDirectory(target); + } + } +}