package org.simantics.acorn; import java.io.FileNotFoundException; import java.io.IOException; 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.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; 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; } private MainState(int headDir) { this.headDir = headDir; } public static MainState load(Path directory, Runnable rollbackCallback) throws IOException { Files.createDirectories(directory); Path mainState = directory.resolve(MAIN_STATE); try { 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); } } 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 { 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); 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 int safeParseInt(int defaultValue, Path p) { try { return Integer.parseInt(p.getFileName().toString()); } catch (NumberFormatException e) { return defaultValue; } } private static boolean isInteger(Path p) { return safeParseInt(Integer.MIN_VALUE, p) != Integer.MIN_VALUE; } private static Predicate isGreaterThan(int i) { return p -> { int pi = safeParseInt(Integer.MIN_VALUE, p); return pi != Integer.MIN_VALUE && pi > i; }; } /** * * @param directory * @param callback * @return * @throws IOException */ 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 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 paths) { for (Path p : paths) if (Files.exists(p.resolve(HeadState.HEAD_STATE))) return true; return false; } @SafeVarargs private static List listRevisionDirs(Path directory, boolean descending, Predicate... filters) throws IOException { int coef = descending ? -1 : 1; try (Stream s = Files.walk(directory, 1)) { Stream fs = s.filter(p -> !p.equals(directory)); for (Predicate 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 deleteAll(Path dir) throws IOException { Files.walkFileTree(dir, new SimpleFileVisitor() { @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; } } }