Skip to content
41 changes: 34 additions & 7 deletions clients/src/main/java/org/apache/kafka/common/utils/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -736,29 +736,56 @@ public static Properties mkProperties(final Map<String, String> properties) {
/**
* Recursively delete the given file/directory and any subfiles (if any exist)
*
* @param file The root file at which to begin deleting
* @param rootFile The root file at which to begin deleting
*/
public static void delete(final File file) throws IOException {
if (file == null)
public static void delete(final File rootFile) throws IOException {
delete(rootFile, Collections.emptyList());
}

/**
* Recursively delete the subfiles (if any exist) of the passed in root file that are not included
* in the list to keep
*
* @param rootFile The root file at which to begin deleting
* @param filesToKeep The subfiles to keep (note that if a subfile is to be kept, so are all its parent
* files in its pat)h; if empty we would also delete the root file
*/
public static void delete(final File rootFile, final List<File> filesToKeep) throws IOException {
if (rootFile == null)
return;
Files.walkFileTree(file.toPath(), new SimpleFileVisitor<Path>() {
Files.walkFileTree(rootFile.toPath(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFileFailed(Path path, IOException exc) throws IOException {
// If the root path did not exist, ignore the error; otherwise throw it.
if (exc instanceof NoSuchFileException && path.toFile().equals(file))
if (exc instanceof NoSuchFileException && path.toFile().equals(rootFile))
return FileVisitResult.TERMINATE;
throw exc;
}

@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
Files.delete(path);
if (!filesToKeep.contains(path.toFile())) {
Files.delete(path);
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult postVisitDirectory(Path path, IOException exc) throws IOException {
Files.delete(path);
// KAFKA-8999: if there's an exception thrown previously already, we should throw it
if (exc != null) {
throw exc;
}

if (rootFile.toPath().equals(path)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit tricky: for the root file, we only consider deleting it if there's no specified skipping sub-files; otherwise we never try to delete since it would doom with DirectoryNotEmpty.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to avoid the unnecessary conversions in this method (rootFile.toPath, path.toFile). We can do some work at the start of the method to improve efficiency.

// only delete the parent directory if there's nothing to keep
if (filesToKeep.isEmpty()) {
Files.delete(path);
}
} else if (!filesToKeep.contains(path.toFile())) {
Files.delete(path);
}

return FileVisitResult.CONTINUE;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Collections;
import java.util.HashMap;
import java.util.regex.Pattern;

Expand All @@ -50,11 +51,12 @@ public class StateDirectory {
static final String LOCK_FILE_NAME = ".lock";
private static final Logger log = LoggerFactory.getLogger(StateDirectory.class);

private final Time time;
private final String appId;
private final File stateDir;
private final boolean createStateDirectory;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about renaming this, eg something like isStateful, hasStatefulTopology`, etc?

It's really confusing to reason about in its usage, for example why createStateDirectory => #lock returns true. You have to backtrack to where createStateDirectory is set to understand this. However, I think it's easy to reason about why isStateful => should create state directory.

Alternatively, what if we have a stateless version of the StateDirectory class that just stubs things where appropriate. Then we could get rid of this altogether. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, I will do the renaming.

private final HashMap<TaskId, FileChannel> channels = new HashMap<>();
private final HashMap<TaskId, LockAndOwner> locks = new HashMap<>();
private final Time time;

private FileChannel globalStateChannel;
private FileLock globalStateLock;
Expand All @@ -75,18 +77,17 @@ private static class LockAndOwner {
* @throws ProcessorStateException if the base state directory or application state directory does not exist
* and could not be created when createStateDirectory is enabled.
*/
public StateDirectory(final StreamsConfig config,
final Time time,
final boolean createStateDirectory) {
public StateDirectory(final StreamsConfig config, final Time time, final boolean createStateDirectory) {
this.time = time;
this.createStateDirectory = createStateDirectory;
this.appId = config.getString(StreamsConfig.APPLICATION_ID_CONFIG);
final String stateDirName = config.getString(StreamsConfig.STATE_DIR_CONFIG);
final File baseDir = new File(stateDirName);
if (this.createStateDirectory && !baseDir.exists() && !baseDir.mkdirs()) {
throw new ProcessorStateException(
String.format("base state directory [%s] doesn't exist and couldn't be created", stateDirName));
}
stateDir = new File(baseDir, config.getString(StreamsConfig.APPLICATION_ID_CONFIG));
stateDir = new File(baseDir, appId);
if (this.createStateDirectory && !stateDir.exists() && !stateDir.mkdir()) {
throw new ProcessorStateException(
String.format("state directory [%s] doesn't exist and couldn't be created", stateDir.getPath()));
Expand All @@ -113,9 +114,13 @@ public File directoryForTask(final TaskId taskId) {
boolean directoryForTaskIsEmpty(final TaskId taskId) {
final File taskDir = directoryForTask(taskId);

return taskDirEmpty(taskDir);
}

private boolean taskDirEmpty(final File taskDir) {
final File[] storeDirs = taskDir.listFiles(pathname ->
!pathname.getName().equals(LOCK_FILE_NAME) &&
!pathname.getName().equals(CHECKPOINT_FILE_NAME));
!pathname.getName().equals(CHECKPOINT_FILE_NAME));

// if the task is stateless, storeDirs would be null
return storeDirs == null || storeDirs.length == 0;
Expand Down Expand Up @@ -252,18 +257,21 @@ synchronized void unlock(final TaskId taskId) throws IOException {
}

public synchronized void clean() {
// remove task dirs
try {
cleanRemovedTasks(0, true);
} catch (final Exception e) {
// this is already logged within cleanRemovedTasks
throw new StreamsException(e);
}
// remove global dir
try {
if (stateDir.exists()) {
Utils.delete(globalStateDir().getAbsoluteFile());
}
} catch (final IOException e) {
log.error("{} Failed to delete global state directory due to an unexpected exception", logPrefix(), e);
log.error("{} Failed to delete global state directory of {} due to an unexpected exception",
appId, logPrefix(), e);
throw new StreamsException(e);
}
}
Expand All @@ -285,7 +293,7 @@ public synchronized void cleanRemovedTasks(final long cleanupDelayMs) {

private synchronized void cleanRemovedTasks(final long cleanupDelayMs,
final boolean manualUserCall) throws Exception {
final File[] taskDirs = listTaskDirectories();
final File[] taskDirs = lisAllTaskDirectories();
if (taskDirs == null || taskDirs.length == 0) {
return; // nothing to do
}
Expand All @@ -294,61 +302,69 @@ private synchronized void cleanRemovedTasks(final long cleanupDelayMs,
final String dirName = taskDir.getName();
final TaskId id = TaskId.parse(dirName);
if (!locks.containsKey(id)) {
Exception exception = null;
try {
if (lock(id)) {
final long now = time.milliseconds();
final long lastModifiedMs = taskDir.lastModified();
if (now > lastModifiedMs + cleanupDelayMs || manualUserCall) {
if (!manualUserCall) {
log.info(
"{} Deleting obsolete state directory {} for task {} as {}ms has elapsed (cleanup delay is {}ms).",
logPrefix(),
dirName,
id,
now - lastModifiedMs,
cleanupDelayMs);
} else {
log.info(
"{} Deleting state directory {} for task {} as user calling cleanup.",
logPrefix(),
dirName,
id);
}
Utils.delete(taskDir);
if (now > lastModifiedMs + cleanupDelayMs) {
log.info("{} Deleting obsolete state directory {} for task {} as {}ms has elapsed (cleanup delay is {}ms).",
logPrefix(), dirName, id, now - lastModifiedMs, cleanupDelayMs);

Utils.delete(taskDir, Collections.singletonList(new File(taskDir, LOCK_FILE_NAME)));
} else if (manualUserCall) {
log.info("{} Deleting state directory {} for task {} as user calling cleanup.",
logPrefix(), dirName, id);

Utils.delete(taskDir, Collections.singletonList(new File(taskDir, LOCK_FILE_NAME)));
}
}
} catch (final OverlappingFileLockException e) {
// locked by another thread
if (manualUserCall) {
log.error("{} Failed to get the state directory lock.", logPrefix(), e);
throw e;
}
} catch (final IOException e) {
log.error("{} Failed to delete the state directory.", logPrefix(), e);
if (manualUserCall) {
throw e;
}
} catch (final OverlappingFileLockException | IOException e) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a minor code clean up.

exception = e;
} finally {
try {
unlock(id);
} catch (final IOException e) {
log.error("{} Failed to release the state directory lock.", logPrefix());

// for manual user call, stream threads are not running so it is safe to delete

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment makes me wonder why we even bother with locking at all for manual calls. If we know there’s no app running, why not just delete the whole state directory and not bother with locks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about that too, and tried it out, it's just that based on manualUserCall to decide whether lock / unlock the block, we ended up with either much more code duplicates or clumsy and finer-grained if else condition, neither of which I like. On the other hand, since streams.cleanUp is usually a one-time thing compared with the periodic internal clean I think having two delete calls are okay to same some code duplication here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok!

// the whole directory
if (manualUserCall) {
throw e;
Utils.delete(taskDir);
}
} catch (final IOException e) {
exception = e;
}
}

if (exception != null && manualUserCall) {
log.error("{} Failed to release the state directory lock.", logPrefix());
throw exception;
}
}
}
}

/**
* List all of the task directories that are non-empty
* @return The list of all the non-empty local directories for stream tasks
*/
File[] listNonEmptyTaskDirectories() {
return !stateDir.exists() ? new File[0] :
stateDir.listFiles(pathname -> {
if (!pathname.isDirectory() || !PATH_NAME.matcher(pathname.getName()).matches()) {
return false;
} else {
return !taskDirEmpty(pathname);
}
});
}

/**
* List all of the task directories
* @return The list of all the existing local directories for stream tasks
*/
File[] listTaskDirectories() {
File[] lisAllTaskDirectories() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
File[] lisAllTaskDirectories() {
File[] listAllTaskDirectories() {

return !stateDir.exists() ? new File[0] :
stateDir.listFiles(pathname -> pathname.isDirectory() && PATH_NAME.matcher(pathname.getName()).matches());
stateDir.listFiles(pathname -> pathname.isDirectory() && PATH_NAME.matcher(pathname.getName()).matches());
}

private FileChannel getOrCreateFileChannel(final TaskId taskId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ private Set<TaskId> tasksOnLocalStorage() {

final Set<TaskId> locallyStoredTasks = new HashSet<>();

final File[] stateDirs = stateDirectory.listTaskDirectories();
final File[] stateDirs = stateDirectory.listNonEmptyTaskDirectories();
if (stateDirs != null) {
for (final File dir : stateDirs) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,18 +249,29 @@ public void shouldReleaseTaskStateDirectoryLock() throws Exception {
public void shouldCleanUpTaskStateDirectoriesThatAreNotCurrentlyLocked() throws Exception {
final TaskId task0 = new TaskId(0, 0);
final TaskId task1 = new TaskId(1, 0);
final TaskId task2 = new TaskId(2, 0);
try {
assertTrue(new File(directory.directoryForTask(task0), "store").mkdir());
assertTrue(new File(directory.directoryForTask(task1), "store").mkdir());
assertTrue(new File(directory.directoryForTask(task2), "store").mkdir());

directory.lock(task0);
directory.lock(task1);
directory.directoryForTask(new TaskId(2, 0));

List<File> files = Arrays.asList(Objects.requireNonNull(appDir.listFiles()));
List<File> files = Arrays.asList(Objects.requireNonNull(directory.lisAllTaskDirectories()));
assertEquals(3, files.size());


files = Arrays.asList(Objects.requireNonNull(directory.listNonEmptyTaskDirectories()));
assertEquals(3, files.size());

time.sleep(1000);
time.sleep(5000);
directory.cleanRemovedTasks(0);

files = Arrays.asList(Objects.requireNonNull(appDir.listFiles()));
files = Arrays.asList(Objects.requireNonNull(directory.lisAllTaskDirectories()));
assertEquals(3, files.size());

files = Arrays.asList(Objects.requireNonNull(directory.listNonEmptyTaskDirectories()));
assertEquals(2, files.size());
assertTrue(files.contains(new File(appDir, task0.toString())));
assertTrue(files.contains(new File(appDir, task1.toString())));
Expand All @@ -273,13 +284,19 @@ public void shouldCleanUpTaskStateDirectoriesThatAreNotCurrentlyLocked() throws
@Test
public void shouldCleanupStateDirectoriesWhenLastModifiedIsLessThanNowMinusCleanupDelay() {
final File dir = directory.directoryForTask(new TaskId(2, 0));
assertTrue(new File(dir, "store").mkdir());

final int cleanupDelayMs = 60000;
directory.cleanRemovedTasks(cleanupDelayMs);
assertTrue(dir.exists());
assertEquals(1, directory.lisAllTaskDirectories().length);
assertEquals(1, directory.listNonEmptyTaskDirectories().length);

time.sleep(cleanupDelayMs + 1000);
directory.cleanRemovedTasks(cleanupDelayMs);
assertFalse(dir.exists());
assertTrue(dir.exists());
assertEquals(1, directory.lisAllTaskDirectories().length);
assertEquals(0, directory.listNonEmptyTaskDirectories().length);
}

@Test
Expand All @@ -290,15 +307,21 @@ public void shouldNotRemoveNonTaskDirectoriesAndFiles() {
}

@Test
public void shouldListAllTaskDirectories() {
public void shouldNotListNonEmptyTaskDirectories() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the double negative intentional here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, will change.

TestUtils.tempDirectory(stateDir.toPath(), "foo");
final File taskDir1 = directory.directoryForTask(new TaskId(0, 0));
final File taskDir2 = directory.directoryForTask(new TaskId(0, 1));

final List<File> dirs = Arrays.asList(directory.listTaskDirectories());
assertEquals(2, dirs.size());
assertTrue(dirs.contains(taskDir1));
assertTrue(dirs.contains(taskDir2));
final File storeDir = new File(taskDir1, "store");
assertTrue(storeDir.mkdir());

assertEquals(Arrays.asList(taskDir1, taskDir2), Arrays.asList(directory.lisAllTaskDirectories()));
assertEquals(Collections.singletonList(taskDir1), Arrays.asList(directory.listNonEmptyTaskDirectories()));

directory.cleanRemovedTasks(0L);

assertEquals(Arrays.asList(taskDir1, taskDir2), Arrays.asList(directory.lisAllTaskDirectories()));
assertEquals(Collections.emptyList(), Arrays.asList(directory.listNonEmptyTaskDirectories()));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public void shouldReturnOffsetsForAllCachedTaskIdsFromDirectory() throws IOExcep
assertThat((new File(taskFolders[1], StateManagerUtil.CHECKPOINT_FILE_NAME)).createNewFile(), is(true));
assertThat((new File(taskFolders[3], StateManagerUtil.CHECKPOINT_FILE_NAME)).createNewFile(), is(true));

expect(stateDirectory.listTaskDirectories()).andReturn(taskFolders).once();
expect(stateDirectory.listNonEmptyTaskDirectories()).andReturn(taskFolders).once();

replay(activeTaskCreator, stateDirectory);

Expand Down