/**
* Copyright (c) Codice Foundation
* <p/>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p/>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package ddf.catalog.backup;
import java.io.File;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.Validate;
import org.codice.ddf.configuration.AbsolutePathResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ddf.catalog.data.Metacard;
import ddf.catalog.data.impl.MetacardImpl;
import ddf.catalog.operation.CreateResponse;
import ddf.catalog.operation.DeleteResponse;
import ddf.catalog.operation.Update;
import ddf.catalog.operation.UpdateResponse;
import ddf.catalog.plugin.PostIngestPlugin;
/**
* The CatalogBackupPlugin backups up metacards to the file system. It is a
* PostIngestPlugin, so it processes CreateResponses, DeleteResponses, and
* UpdateResponses.
* <p/>
* The root backup directory and subdirectory levels can be configured in the
* Backup Post-Ingest Plugin section in the admin console.
* <p/>
* This feature can be installed/uninstalled with the following commands:
* <p/>
* ddf@local>feature:install catalog-core-backupplugin
* ddf@local>feature:uninstall catalog-core-backupplugin
*/
public class CatalogBackupPlugin implements PostIngestPlugin {
public static final String CREATE = "CREATE";
public static final String DELETE = "DELETE";
private static final Logger LOGGER = LoggerFactory.getLogger(CatalogBackupPlugin.class);
private static final String TEMP_FILE_EXTENSION = ".tmp";
private long terminationTimeoutSeconds;
private int subDirLevels = 0;
private String rootBackupDir;
private ExecutorService executor;
private File rootDirOjbect;
/**
* Backs up created metacards to the file system backup.
*
* @param input the {@link CreateResponse} to process
* @return {@link CreateResponse}
*/
@Override
public CreateResponse process(CreateResponse input) {
execute(() -> create(input.getCreatedMetacards()));
return input;
}
/**
* Backs up updated metacards to the file system backup.
*
* @param input the {@link UpdateResponse} to process
* @return {@link UpdateResponse}
*/
@Override
public UpdateResponse process(UpdateResponse input) {
int size = input.getUpdatedMetacards()
.size();
List<Metacard> toDelete = new ArrayList<>(size);
List<Metacard> toCreate = new ArrayList<>(size);
for (Update update : input.getUpdatedMetacards()) {
toDelete.add(update.getOldMetacard());
toCreate.add(update.getNewMetacard());
}
execute(() -> delete(toDelete));
execute(() -> create(toCreate));
return input;
}
/**
* Removes deleted metacards from the file system backup.
*
* @param input the {@link DeleteResponse} to process
* @return {@link DeleteResponse}
*/
@Override
public DeleteResponse process(DeleteResponse input) {
execute(() -> delete(input.getDeletedMetacards()));
return input;
}
/**
* @throws IllegalStateException will be thrown if the tasks in the queue have not
* completed before the awaitTermination message times out
*/
public void shutdown() {
getExecutor().shutdown();
if (!executor.isShutdown()) {
try {
executor.awaitTermination(getTerminationTimeoutSeconds(), TimeUnit.SECONDS);
// If the remaining jobs in the queue are not completed TERMINATION_TIMEOUT elapses
// The JVM will throw an Interrupted Exception.
} catch (InterruptedException e) {
LOGGER.warn(
"Backup of metacards interrupted. Some metacards might not be backed up.");
throw new IllegalStateException(e);
}
final List<Runnable> failures = executor.shutdownNow();
if (failures.size() > 0) {
LOGGER.warn(
"Cancelled tasks to backup metacards. Some metacards might not be backed up.");
}
}
}
ExecutorService getExecutor() {
return executor;
}
public void setExecutor(ExecutorService executor) {
this.executor = executor;
}
private void execute(Runnable task) {
executor.execute(task);
}
private void create(List<Metacard> metacards) {
List<String> errors = new ArrayList<>();
for (Metacard metacard : metacards) {
try {
createFile(metacard);
} catch (RuntimeException | IOException e) {
errors.add(metacard.getId());
}
}
if (!errors.isEmpty()) {
LOGGER.warn(getExceptionMessage(errors, CREATE));
}
}
private void delete(List<Metacard> cards) {
List<String> errors = new ArrayList<>();
for (Metacard metacard : cards) {
try {
deleteFile(metacard);
} catch (IOException | RuntimeException e) {
errors.add(metacard.getId());
}
}
if (!errors.isEmpty()) {
LOGGER.warn(getExceptionMessage(errors, DELETE));
}
}
private void renameTempFile(File source) throws IOException {
File destination = new File(StringUtils.removeEnd(source.getAbsolutePath(),
TEMP_FILE_EXTENSION));
boolean success = source.renameTo(destination);
if (!success) {
LOGGER.debug("Failed to move {} to {}.",
source.getAbsolutePath(),
destination.getAbsolutePath());
}
}
private String getExceptionMessage(List<String> metacardsIdsInError, String operation) {
return "Catalog Backup Plugin processing error." + " " + "Unable to " + operation
+ " metacard(s) [" + StringUtils.join(metacardsIdsInError, ",") + "]. ";
}
private void createFile(Metacard metacard) throws IOException {
// Metacards written to temp files. Each temp file is renamed when the write is
// complete. This makes it easy to find and remove failed files.
File tempFile = getFile(metacard.getId(), TEMP_FILE_EXTENSION);
try (ObjectOutputStream oos = new ObjectOutputStream(FileUtils.openOutputStream(tempFile))) {
oos.writeObject(new MetacardImpl(metacard));
}
renameTempFile(tempFile);
}
private void deleteFile(Metacard metacard) throws IOException {
File metacardToDelete = getFile(metacard.getId(), "");
FileUtils.forceDelete(metacardToDelete);
}
private File getFile(String id, String extension) throws IOException {
int depth = getDepth(id.length());
return new File(getCompleteDirectory(depth, id), id + extension);
}
private File getCompleteDirectory(int depth, String id) throws IOException {
File parent = getRootDirObject();
for (int i = 0; i < depth; i++) {
parent = new File(parent, id.substring(i * 2, i * 2 + 2));
}
return parent;
}
private int getDepth(int idLength) {
int levels = getSubDirLevels();
if (idLength == 1 || idLength < getSubDirLevels() * 2) {
levels = idLength / 2;
}
return levels;
}
private File getRootDirObject() throws IOException {
if (rootDirOjbect == null) {
Validate.notNull(getRootBackupDir());
File directory = new File(getRootBackupDir());
FileUtils.forceMkdir(directory);
validateDirectory(directory);
rootDirOjbect = directory;
}
return rootDirOjbect;
}
private void validateDirectory(File directory) {
if (!(directory.isDirectory() && directory.canWrite())) {
throw new IllegalArgumentException("Directory " + directory.getAbsolutePath()
+ " does not exist or is not writable");
}
}
public long getTerminationTimeoutSeconds() {
return terminationTimeoutSeconds;
}
public void setTerminationTimeoutSeconds(long terminationTimeoutSeconds) {
this.terminationTimeoutSeconds = terminationTimeoutSeconds;
}
public String getRootBackupDir() {
return rootBackupDir;
}
/**
* Sets the root file system backup directory. The directory will be created when
* it is needed. Do not validate the existence of the directory until then.
*
* @param dir absolute path for the root file system backup directory.
*/
public void setRootBackupDir(String dir) {
rootBackupDir = new AbsolutePathResolver(dir).getPath();
rootDirOjbect = null;
}
public int getSubDirLevels() {
return subDirLevels;
}
/**
* Sets the number of subdirectory levels to create. Two characters from
* each metacard ID will be used to name each subdirectory level.
*
* @param levels number of subdirectory levels to create
*/
public void setSubDirLevels(int levels) {
Validate.isTrue(levels >= 0,
"Depth of directory hierarchy for the catalog backup plugin must be zero or greater. Actual value was ",
levels);
this.subDirLevels = levels;
}
}