Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 18: Housekeeping shouldn't fail when attempting to cleanup a path that is already deleted #19

Merged
merged 13 commits into from
Apr 25, 2018
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## TBD
### Changed
* Default Housekeeping configuration is now provided via `@Bean` `housekeepingEnvironment` only if the bean is not already provided.
### Fixed
* Fixed issue where Housekeeping was failing when database contained a path which no longer exists. See [#18](https://github.com/HotelsDotCom/housekeeping/issues/18).

## [1.0.2] 2018-02-15
### Added
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/com/hotels/housekeeping/HousekeepingException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright (C) 2016-2018 Expedia Inc.
*
* Licensed 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 com.hotels.housekeeping;

public class HousekeepingException extends RuntimeException {
private static final long serialVersionUID = 1L;

public HousekeepingException(Exception e) {
super(e);
}

public HousekeepingException(String message) {
super(message);
}

public HousekeepingException(String message, Throwable cause) {
super(message, cause);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.orm.ObjectOptimisticLockingFailureException;

import com.hotels.housekeeping.HousekeepingException;
import com.hotels.housekeeping.model.LegacyReplicaPath;
import com.hotels.housekeeping.repository.LegacyReplicaPathRepository;
import com.hotels.housekeeping.service.HousekeepingService;
Expand All @@ -47,39 +48,62 @@ public FileSystemHousekeepingService(LegacyReplicaPathRepository legacyReplicaPa
LOG.warn("{}.fixIncompleteRecord(LegacyReplicaPath) should be removed in future.", getClass());
}

private FileSystem fileSystemForPath(Path path) {
try {
return path.getFileSystem(conf);
} catch (IOException e) {
throw new HousekeepingException(e);
}
}

private void housekeepPath(LegacyReplicaPath cleanUpPath) {
final Path path = new Path(cleanUpPath.getPath());
final Path rootPath;
final FileSystem fs;
try {
fs = fileSystemForPath(new Path(cleanUpPath.getPath()));
LOG.info("Attempting to delete path '{}' from file system", cleanUpPath);
if (fs.exists(path)) {
fs.delete(path, true);
LOG.info("Path '{}' has been deleted from file system", cleanUpPath);
} else {
LOG.warn("Path '{}' does not exist.", cleanUpPath);
}
rootPath = deleteParents(fs, path, cleanUpPath.getPathEventId());
} catch (Exception e) {
LOG.warn("Unable to delete path '{}' from file system. Will try next time. {}", cleanUpPath, e.getMessage());
return;
}

try {
if (oneOfMySiblingsWillTakeCareOfMyAncestors(path, rootPath, fs) || thereIsNothingMoreToDelete(fs, rootPath)) {
// BEWARE the eventual consistency of your blobstore!
try {
LOG.info("Deleting path '{}' from housekeeping database", cleanUpPath);
legacyReplicaPathRepository.delete(cleanUpPath);
} catch (ObjectOptimisticLockingFailureException e) {
LOG.debug(
"Failed to delete path '{}': probably already cleaned up by process running at same time. Ok to ignore. {}",
cleanUpPath, e.getMessage());
}
}
} catch (Exception e) {
LOG.warn("Path '{}' was not deleted from the housekeeping database. {}", cleanUpPath, e.getMessage());
Copy link
Contributor

Choose a reason for hiding this comment

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

This does prevent housekeeping from failing but it won't delete the path from the repo, so we'll get this warning on every run of housekeeping or am I missing something?

Copy link
Author

Choose a reason for hiding this comment

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

This block is only reached if legacyReplicaPathRepository.delete(cleanUpPath); fails, signifying a failure to delete the specified path from the Housekeeping database

Copy link
Contributor

Choose a reason for hiding this comment

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

if oneOfMySiblingsWillTakeCareOfMyAncestors(path, rootPath, fs) || thereIsNothingMoreToDelete(fs, rootPath) throws an exception which I thought was why we got the error in the first place, we'll hit the catch

Copy link
Author

Choose a reason for hiding this comment

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

Ah right, I was looking at wrong try block.. I'm hesitant to delete data from the Housekeeping database here as it may fail for another unforeseen reason that isn't the path being deleted already, do you think we should just do the deletion anyway?

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we need to know exactly at what point we got the error and check what path we were trying to delete is that some parent path or something else? What would happen if we warn and run again, failure at same place?

Copy link
Author

Choose a reason for hiding this comment

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

This becomes complex cause of eventual consistency and the likes, I would rather leave it like this, they will get a small warning in their logs when they run their application but I would rather that over leaving behind data that I may be paying for

Copy link
Contributor

Choose a reason for hiding this comment

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

ok let's go for this, if the warnings become an issue we can revisit.

}
}

@Override
public void cleanUp(Instant referenceTime) {
try {
List<LegacyReplicaPath> pathsToDelete = legacyReplicaPathRepository
.findByCreationTimestampLessThanEqual(referenceTime.getMillis());
for (LegacyReplicaPath cleanUpPath : pathsToDelete) {
cleanUpPath = fixIncompleteRecord(cleanUpPath);
LOG.debug("Deleting path '{}' from file system", cleanUpPath);
Path path = new Path(cleanUpPath.getPath());
FileSystem fs = path.getFileSystem(conf);
Path rootPath;
try {
fs.delete(path, true);
rootPath = deleteParents(fs, path, cleanUpPath.getPathEventId());
LOG.debug("Path '{}' has been deleted from file system", cleanUpPath);
} catch (Exception e) {
LOG.warn("Unable to delete path '{}' from file system. Will try next time", cleanUpPath, e);
continue;
}
if (oneOfMySiblingsWillTakeCareOfMyAncestors(path, rootPath, fs) || thereIsNothingMoreToDelete(fs, rootPath)) {
// BEWARE the eventual consistency of your blobstore!
try {
LOG.debug("Deleting path '{}' from database", cleanUpPath);
legacyReplicaPathRepository.delete(cleanUpPath);
} catch (ObjectOptimisticLockingFailureException e) {
LOG.debug(
"Failed to delete path '{}': probably already cleaned up by process running at same time. Ok to ignore",
cleanUpPath);
}
}
housekeepPath(cleanUpPath);
}
} catch (Exception e) {
throw new RuntimeException(format("Unable to execute housekeeping at instant %d", referenceTime.getMillis()), e);
throw new HousekeepingException(format("Unable to execute housekeeping at instant %d", referenceTime.getMillis()),
e);
}
}

Expand Down Expand Up @@ -110,7 +134,7 @@ private Path deleteParents(FileSystem fs, Path path, String eventId) throws IOEx
}
Path parent = path.getParent();
if (fs.exists(parent) && isEmpty(fs, parent)) {
LOG.debug("Deleting parent path '{}'", parent);
LOG.info("Deleting parent path '{}'", parent);
fs.delete(parent, false);
}
return deleteParents(fs, parent, eventId);
Expand All @@ -125,7 +149,7 @@ public void scheduleForHousekeeping(LegacyReplicaPath cleanUpPath) {
try {
legacyReplicaPathRepository.save(cleanUpPath);
} catch (Exception e) {
throw new RuntimeException(format("Unable to schedule path %s for deletion", cleanUpPath.getPath()), e);
throw new HousekeepingException(format("Unable to schedule path %s for deletion", cleanUpPath.getPath()), e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.verifyPrivate;

import java.io.File;
import java.io.IOException;
Expand All @@ -49,6 +53,7 @@
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
Expand All @@ -58,7 +63,7 @@
import com.hotels.housekeeping.repository.LegacyReplicaPathRepository;

@RunWith(PowerMockRunner.class)
@PrepareForTest(FileSystem.class)
@PrepareForTest({ FileSystemHousekeepingService.class, FileSystem.class })
public class FileSystemHousekeepingServiceTest {

@Rule
Expand Down Expand Up @@ -93,7 +98,7 @@ public void init() throws Exception {
val1Path = new Path(tmpFolder.newFolder("foo", "bar", PATH_EVENT_ID, "test=1", "val=1").getCanonicalPath());
val2Path = new Path(tmpFolder.newFolder("foo", "bar", PATH_EVENT_ID, "test=1", "val=2").getCanonicalPath());
val3Path = new Path(tmpFolder.newFolder("foo", "bar", PATH_EVENT_ID, "test=1", "val=3").getCanonicalPath());
service = new FileSystemHousekeepingService(legacyReplicationPathRepository, conf);
service = PowerMockito.spy(new FileSystemHousekeepingService(legacyReplicationPathRepository, conf));
cleanUpPath1 = new HousekeepingLegacyReplicaPath(EVENT_ID, PATH_EVENT_ID, val1Path.toString());
cleanUpPath2 = new HousekeepingLegacyReplicaPath(EVENT_ID, PATH_EVENT_ID, val2Path.toString());
cleanUpPath3 = new HousekeepingLegacyReplicaPath(EVENT_ID, PATH_EVENT_ID, val3Path.toString());
Expand Down Expand Up @@ -124,6 +129,79 @@ public void cleanUp() throws Exception {
deleted(eventPath);
}

@Test
public void housekeepingPathsWithOneFileSystemLoadFailureCleansUpOtherPaths() throws Exception {
PowerMockito.doReturn(null).when(service, "fileSystemForPath", eq(new Path(cleanUpPath1.getPath())));
PowerMockito.doReturn(spyFs).when(service, "fileSystemForPath", eq(new Path(cleanUpPath2.getPath())));
PowerMockito.doReturn(spyFs).when(service, "fileSystemForPath", eq(new Path(cleanUpPath3.getPath())));

when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1, cleanUpPath2, cleanUpPath3));

service.cleanUp(now);

verify(spyFs, times(0)).delete(eq(new Path(cleanUpPath1.getPath())), eq(true));
verify(spyFs).delete(eq(new Path(cleanUpPath2.getPath())), eq(true));
verify(spyFs).delete(eq(new Path(cleanUpPath3.getPath())), eq(true));

verify(legacyReplicationPathRepository, times(0)).delete(cleanUpPath1);
verify(legacyReplicationPathRepository).delete(cleanUpPath2);
verify(legacyReplicationPathRepository).delete(cleanUpPath3);
}

@Test
public void housekeepingPathThatDoesntExistSkipsDeleteAndRemovesPathFromHousekeepingDatabase() throws Exception {
when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1));
doReturn(false).when(spyFs).exists(any(Path.class));

service.cleanUp(now);

verify(spyFs, times(0)).delete(eq(new Path(cleanUpPath1.getPath())), eq(true));
verifyPrivate(service).invoke("deleteParents", eq(spyFs), eq(new Path(cleanUpPath1.getPath())), anyString());
verify(legacyReplicationPathRepository, times(1)).delete(cleanUpPath1);
}

@Test
public void deleteFailureDoesntRemovePathFromHousekeepingDatabase() throws Exception {
when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1));
doThrow(new RuntimeException()).when(spyFs).delete(eq(new Path(cleanUpPath1.getPath())), eq(true));

service.cleanUp(now);

verify(spyFs, times(1)).delete(eq(new Path(cleanUpPath1.getPath())), eq(true));
verify(legacyReplicationPathRepository, times(0)).delete(cleanUpPath1);
}

@Test
public void siblingCheckFailureDoesntFailHousekeeping() throws Exception {
PowerMockito.doThrow(new RuntimeException()).when(service, "oneOfMySiblingsWillTakeCareOfMyAncestors",
any(Path.class), any(Path.class), any(FileSystem.class));

when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1));

service.cleanUp(now);

verify(legacyReplicationPathRepository, times(0)).delete(cleanUpPath1);
}

@Test
public void nothingMoreToDeleteFailureDoesntFailHousekeeping() throws Exception {
PowerMockito.doThrow(new RuntimeException()).when(service, "thereIsNothingMoreToDelete", any(FileSystem.class),
any(Path.class));
PowerMockito.doReturn(false).when(service, "oneOfMySiblingsWillTakeCareOfMyAncestors", any(Path.class),
any(Path.class), any(FileSystem.class));

when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1));

service.cleanUp(now);

verify(legacyReplicationPathRepository, times(0)).delete(cleanUpPath1);
}

@Test
public void eventuallyConsistentCleanUpFull() throws Exception {
when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
Expand Down Expand Up @@ -201,7 +279,7 @@ public void eventuallyConsistentCleanUpOnlyKeys() throws Exception {
.thenReturn(Arrays.asList(cleanUpPath4));

doCallRealMethod().when(spyFs).delete(val4Path, true);
doReturn(false).when(spyFs).exists(val4Path);
doCallRealMethod().when(spyFs).exists(val4Path);
doReturn(false).when(spyFs).exists(eventPath);
service.cleanUp(now);

Expand Down Expand Up @@ -253,8 +331,8 @@ public void filesystemParentPathDeletionFailsKeepsCleanUpPathForRetry() throws E
deleted(val1Path, val2Path, val3Path);
}

@Test(expected = RuntimeException.class)
public void repositoryDeletionFails() throws Exception {
@Test
public void repositoryDeletionFailsHousekeepingContinues() throws Exception {
doThrow(new RuntimeException()).when(legacyReplicationPathRepository).delete(cleanUpPath1);
when(legacyReplicationPathRepository.findByCreationTimestampLessThanEqual(now.getMillis()))
.thenReturn(Arrays.asList(cleanUpPath1));
Expand Down