package org.simantics.acorn;
-import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
-import java.io.OutputStream;
import java.io.Serializable;
+import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
import java.util.List;
-import java.util.function.Consumer;
+import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.simantics.acorn.exception.InvalidHeadStateException;
import org.simantics.databoard.Bindings;
import org.simantics.databoard.binding.mutable.MutableVariant;
-import org.simantics.databoard.file.RuntimeIOException;
-import org.simantics.databoard.serialization.Serializer;
import org.simantics.databoard.util.binary.BinaryMemory;
-import org.simantics.utils.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class MainState implements Serializable {
private static final long serialVersionUID = 6237383147637270225L;
+ private static final Logger LOGGER = LoggerFactory.getLogger(MainState.class);
+
public static final String MAIN_STATE = "main.state";
-
- public int headDir = 0;
+
+ public int headDir;
public MainState() {
+ this.headDir = 0;
}
-
+
private MainState(int headDir) {
this.headDir = headDir;
}
- public static MainState load(Path directory, Consumer<Exception> callback) throws IOException {
+ public static MainState load(Path directory, Runnable rollbackCallback) throws IOException {
Files.createDirectories(directory);
Path mainState = directory.resolve(MAIN_STATE);
try {
- byte[] bytes = Files.readAllBytes(mainState);
- MainState state = null;
- try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes)) {
- state = (MainState) org.simantics.databoard.Files.readFile(bais, Bindings.getBindingUnchecked(MainState.class));
+ MainState state = (MainState) org.simantics.databoard.Files.readFile(
+ mainState.toFile(),
+ Bindings.getBindingUnchecked(MainState.class));
+ int latestRevision = state.headDir - 1;
+ try {
+ HeadState.validateHeadStateIntegrity(directory.resolve(latestRevision + "/" + HeadState.HEAD_STATE));
+ archiveRevisionDirectories(directory, latestRevision, rollbackCallback);
+ return state;
+ } catch (InvalidHeadStateException e) {
+ LOGGER.warn("Failed to start database from revision " + latestRevision + " stored in " + mainState + ". " + HeadState.HEAD_STATE + " is invalid.");
+ return rollback(directory, rollbackCallback);
+ } catch (FileNotFoundException e) {
+ LOGGER.warn("Failed to start database from revision " + latestRevision + " stored in " + mainState + ". Revision does not contain " + HeadState.HEAD_STATE + ".");
+ return rollback(directory, rollbackCallback);
}
-
- while (true) {
- Path latest = directory.resolve(Integer.toString(state.headDir - 1));
- try {
- Path headState = latest.resolve(HeadState.HEAD_STATE);
- HeadState.validateHeadStateIntegrity(headState);
- break;
- } catch (InvalidHeadStateException e) {
- e.printStackTrace();
- state.headDir--;
- callback.accept(e);
- } finally {
- cleanBaseDirectory(directory, latest, callback);
- }
- }
- return state;
- } catch(Exception i) {
- callback.accept(i);
- int largest = -1;
- Path latest = findNewHeadStateDir(directory, callback);
- if (latest != null)
- largest = safeParseInt(-1, latest.getFileName().toString());
- // +1 because we want to return the next head version to use,
- // not the latest existing version.
- largest++;
- MainState state = new MainState( largest );
- cleanBaseDirectory(directory, latest, callback);
- return state;
+ } catch (FileNotFoundException e) {
+ // The database may also be totally empty at this point
+ if (!listRevisionDirs(directory, true, MainState::isInteger).isEmpty())
+ return new MainState(0);
+
+ LOGGER.warn("Unclean exit detected, " + mainState + " not found. Initiating automatic rollback.");
+ return rollback(directory, rollbackCallback);
+ } catch (Exception e) {
+ LOGGER.warn("Unclean exit detected. Initiating automatic rollback.", e);
+ return rollback(directory, rollbackCallback);
} finally {
- if (Files.exists(mainState)) {
- Files.delete(mainState);
- }
+ Files.deleteIfExists(mainState);
+ }
+ }
+
+ private static MainState rollback(Path directory, Runnable rollbackCallback) throws IOException {
+ LOGGER.warn("Database rollback initiated for " + directory);
+ rollbackCallback.run();
+ Path latest = findNewHeadStateDir(directory, rollbackCallback);
+ int latestRevision = latest != null ? safeParseInt(-1, latest) : -1;
+ // +1 because we want to return the next head version to use,
+ // not the latest existing version.
+ MainState state = new MainState( latestRevision + 1 );
+ archiveRevisionDirectories(directory, latestRevision, rollbackCallback);
+ LOGGER.warn("Database rollback completed. Restarting database from revision " + latest);
+ return state;
+ }
+
+ private byte[] toByteArray() throws IOException {
+ try (BinaryMemory rf = new BinaryMemory(4096)) {
+ Bindings.getSerializerUnchecked(Bindings.VARIANT).serialize(rf, MutableVariant.ofInstance(this));
+ return rf.toByteBuffer().array();
}
}
public void save(Path directory) throws IOException {
Path f = directory.resolve(MAIN_STATE);
- BinaryMemory rf = new BinaryMemory(4096);
- try {
- MutableVariant v = new MutableVariant(Bindings.getBindingUnchecked(MainState.class), this);
- Serializer s = Bindings.getSerializerUnchecked( Bindings.VARIANT );
- s.serialize(rf, v);
- } finally {
- rf.close();
- }
- byte[] bytes = rf.toByteBuffer().array();
- try (OutputStream out = Files.newOutputStream(f)) {
- out.write(bytes);
- }
+ Files.write(f, toByteArray());
FileIO.syncPath(f);
}
- private static boolean isInteger(Path p) {
+ private static int safeParseInt(int defaultValue, Path p) {
try {
- Integer.parseInt(p.getFileName().toString());
- return true;
+ return Integer.parseInt(p.getFileName().toString());
} catch (NumberFormatException e) {
- return false;
+ return defaultValue;
}
}
+ private static boolean isInteger(Path p) {
+ return safeParseInt(Integer.MIN_VALUE, p) != Integer.MIN_VALUE;
+ }
+
+ private static Predicate<Path> isGreaterThan(int i) {
+ return p -> {
+ int pi = safeParseInt(Integer.MIN_VALUE, p);
+ return pi != Integer.MIN_VALUE && pi > i;
+ };
+ }
+
/**
*
* @param directory
* @return
* @throws IOException
*/
- private static Path findNewHeadStateDir(Path directory, Consumer<Exception> callback) throws IOException {
- try (Stream<Path> s = Files.walk(directory, 1)) {
- List<Path> reverseSortedPaths = s
- .filter(p -> !p.equals(directory) && isInteger(p) && Files.isDirectory(p))
- .sorted((p1, p2) -> {
- int p1Name = Integer.parseInt(p1.getFileName().toString());
- int p2Name = Integer.parseInt(p2.getFileName().toString());
- return Integer.compare(p2Name, p1Name);
- }).collect(Collectors.toList());
-
- Path latest = null;
- for (Path last : reverseSortedPaths) {
- Path headState = last.resolve(HeadState.HEAD_STATE);
- try {
- HeadState.validateHeadStateIntegrity(headState);
- latest = last;
- break;
- } catch (IOException | InvalidHeadStateException e) {
- // Cleanup is done in {@link cleanBaseDirectory} method
- callback.accept(e);
- }
+ private static Path findNewHeadStateDir(Path directory, Runnable rollbackCallback) throws IOException {
+ for (Path last : listRevisionDirs(directory, true, MainState::isInteger)) {
+ try {
+ HeadState.validateHeadStateIntegrity(last.resolve(HeadState.HEAD_STATE));
+ return last;
+ } catch (IOException | InvalidHeadStateException e) {
+ // Cleanup is done in {@link cleanRevisionDirectories} method
+ rollbackCallback.run();
}
- return latest;
}
+ return null;
}
- private static int safeParseInt(int defaultValue, String s) {
- try {
- return Integer.parseInt(s);
- } catch (NumberFormatException e) {
- return defaultValue;
+ private static void archiveRevisionDirectories(Path directory, int greaterThanRevision, Runnable rollbackCallback) throws IOException {
+ List<Path> reverseSortedPaths = listRevisionDirs(directory, true, isGreaterThan(greaterThanRevision));
+ if (reverseSortedPaths.isEmpty())
+ return;
+
+ // If none of the revisions to be archived are actually committed revisions
+ // then just delete them. Commitment is indicated by the head.state file.
+ if (!anyContainsHeadState(reverseSortedPaths)) {
+ for (Path p : reverseSortedPaths) {
+ deleteAll(p);
+ LOGGER.info("Removed useless working folder " + p);
+ }
+ return;
}
+
+ // Some kind of rollback is being performed. There is a possibility that
+ // indexes and virtual graphs are out of sync with the persistent database.
+ rollbackCallback.run();
+
+ Path recoveryFolder = getRecoveryFolder(directory);
+ Files.createDirectories(recoveryFolder);
+ LOGGER.info("Created new database recovery folder " + recoveryFolder);
+ for (Path p : reverseSortedPaths) {
+ Files.move(p, recoveryFolder.resolve(p.getFileName().toString()));
+ LOGGER.info("Archived revision " + p + " in recovery folder " + recoveryFolder);
+ }
+ }
+
+ private static boolean anyContainsHeadState(List<Path> paths) {
+ for (Path p : paths)
+ if (Files.exists(p.resolve(HeadState.HEAD_STATE)))
+ return true;
+ return false;
}
- private static void cleanBaseDirectory(Path directory, Path latest, Consumer<Exception> callback) throws IOException {
+ @SafeVarargs
+ private static List<Path> listRevisionDirs(Path directory, boolean descending, Predicate<Path>... filters) throws IOException {
+ int coef = descending ? -1 : 1;
try (Stream<Path> s = Files.walk(directory, 1)) {
- List<Path> reverseSortedPaths = s
- .filter(p -> !p.equals(directory) && isInteger(p) && Files.isDirectory(p))
- .sorted((p1, p2) -> {
- int p1Name = Integer.parseInt(p1.getFileName().toString());
- int p2Name = Integer.parseInt(p2.getFileName().toString());
- return Integer.compare(p2Name, p1Name);
- }).collect(Collectors.toList());
-
- for (Path p : reverseSortedPaths) {
- if (!p.equals(latest)) {
- if (Files.exists(p.resolve(HeadState.HEAD_STATE))) {
- // this indicates that there is a possibility that index and vg's are out of sync
- // if we are able to find folders with higher number than the current head.state
- callback.accept(null);
- }
- uncheckedDeleteAll(p);
- } else {
- break;
- }
- }
+ Stream<Path> fs = s.filter(p -> !p.equals(directory));
+ for (Predicate<Path> p : filters)
+ fs = fs.filter(p);
+ return fs.filter(Files::isDirectory)
+ .sorted((p1, p2) -> coef * Integer.compare(Integer.parseInt(p1.getFileName().toString()),
+ Integer.parseInt(p2.getFileName().toString())))
+ .collect(Collectors.toList());
}
}
- private static void uncheckedDeleteAll(Path path) {
- try {
- FileUtils.deleteAll(path.toFile());
- } catch (IOException e) {
- throw new RuntimeIOException(e);
+ private static void deleteAll(Path dir) throws IOException {
+ Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
+ Files.delete(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
+ private static final DateTimeFormatter RECOVERY_DIR_FORMAT = DateTimeFormatter.ofPattern("yyyy-M-d_HH-mm-ss");
+
+ private static Path getRecoveryFolder(Path directory) {
+ return findNonexistentDir(
+ directory.resolve("recovery"),
+ RECOVERY_DIR_FORMAT.format(ZonedDateTime.now()));
+ }
+
+ private static Path findNonexistentDir(Path inDirectory, String prefix) {
+ for (int i = 0;; ++i) {
+ Path dir = inDirectory.resolve(i == 0 ? prefix : prefix + "-" + i);
+ if (Files.notExists(dir))
+ return dir;
}
}