package org.simantics.acorn;
-import java.io.BufferedInputStream;
-import java.io.BufferedOutputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
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.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import org.simantics.databoard.file.RuntimeIOException;
-import org.simantics.utils.FileUtils;
+import org.simantics.databoard.Bindings;
+import org.simantics.databoard.binding.mutable.MutableVariant;
+import org.simantics.databoard.util.binary.BinaryMemory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class MainState implements Serializable {
private static final long serialVersionUID = 6237383147637270225L;
- public int headDir = 0;
+ private static final Logger LOGGER = LoggerFactory.getLogger(MainState.class);
+
+ public static final String MAIN_STATE = "main.state";
+
+ public int headDir;
public MainState() {
+ this.headDir = 0;
}
- public MainState(int headDir) {
+ private MainState(int headDir) {
this.headDir = headDir;
}
+
+ public boolean isInitial() {
+ return this.headDir == 0;
+ }
- public static MainState load(Path directory) throws IOException {
+ public static MainState load(Path directory, Runnable rollbackCallback) throws IOException {
Files.createDirectories(directory);
- Path f = directory.resolve("main.state");
+ Path mainState = directory.resolve(MAIN_STATE);
try {
- MainState state = null;
- try (ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
- state = (MainState) in.readObject();
- }
- while (true) {
- Path last = directory.resolve(Integer.toString(state.headDir - 1));
- try {
- Path headState = last.resolve("head.state");
- HeadState.validateHeadStateIntegrity(headState);
- break;
- } catch (InvalidHeadStateException e) {
- e.printStackTrace();
- state.headDir--;
- uncheckedDeleteAll(last);
+ MainState state = (MainState) org.simantics.databoard.Files.readFile(
+ mainState.toFile(),
+ Bindings.getBindingUnchecked(MainState.class));
+ int latestRevision = state.headDir - 1;
+ try {
+ if (HeadState.validateHeadStateIntegrity(directory.resolve(latestRevision + "/" + HeadState.HEAD_STATE))) {
+ archiveRevisionDirectories(directory, latestRevision, rollbackCallback);
+ return state;
}
+ 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);
}
- return state;
- } catch(IOException i) {
- return new MainState( findNewHeadState(directory) );
- } catch(ClassNotFoundException c) {
- throw new Error("MainState class not found", c);
+ } 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(f)) {
- Files.delete(f);
- }
+ Files.deleteIfExists(mainState);
}
}
- public void save(Path directory) throws IOException {
- Path f = directory.resolve("main.state");
- try (ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(Files.newOutputStream(f)))) {
- out.writeObject(this);
+ private static MainState rollback(Path directory, Runnable rollbackCallback) throws IOException {
+ LOGGER.warn("Database rollback initiated for " + directory);
+ rollbackCallback.run();
+ Path latest = findNewHeadStateDir(directory);
+ 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);
+ 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;
+ };
+ }
+
/**
- * TODO> shouldn't do two things in the same function, this does both head.state search and directory cleanup
*
* @param directory
+ * @param callback
* @return
* @throws IOException
*/
- private static int findNewHeadState(Path directory) 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());
-
- int largest = -1;
- for (Path last : reverseSortedPaths) {
- Path headState = last.resolve("head.state");
- if (Files.exists(headState)) {
- try {
- HeadState.validateHeadStateIntegrity(headState);
- largest = safeParseInt(-1, last.getFileName().toString());
- break;
- } catch (IOException | InvalidHeadStateException e) {
- e.printStackTrace();
- uncheckedDeleteAll(last);
- }
- } else {
- uncheckedDeleteAll(last);
- }
+ private static Path findNewHeadStateDir(Path directory) throws IOException {
+ for (Path last : listRevisionDirs(directory, true, MainState::isInteger))
+ if (HeadState.validateHeadStateIntegrity(last.resolve(HeadState.HEAD_STATE)))
+ return last;
+ return null;
+ }
+
+ 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);
}
- // +1 because we want to return the next head version to use,
- // not the latest existing version.
- return largest + 1;
+ 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 int safeParseInt(int defaultValue, String s) {
- try {
- return Integer.parseInt(s);
- } catch (NumberFormatException e) {
- return defaultValue;
+ private static boolean anyContainsHeadState(List<Path> paths) {
+ for (Path p : paths)
+ if (Files.exists(p.resolve(HeadState.HEAD_STATE)))
+ return true;
+ return false;
+ }
+
+ @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)) {
+ 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;
}
}