63fc5d4937b06cd0aff10b571506e66830741535
[simantics/platform.git] / bundles / org.simantics.acorn / src / org / simantics / acorn / MainState.java
1 package org.simantics.acorn;
2
3 import java.io.FileNotFoundException;
4 import java.io.IOException;
5 import java.io.Serializable;
6 import java.nio.file.FileVisitResult;
7 import java.nio.file.Files;
8 import java.nio.file.Path;
9 import java.nio.file.SimpleFileVisitor;
10 import java.nio.file.attribute.BasicFileAttributes;
11 import java.time.ZonedDateTime;
12 import java.time.format.DateTimeFormatter;
13 import java.util.List;
14 import java.util.function.Predicate;
15 import java.util.stream.Collectors;
16 import java.util.stream.Stream;
17
18 import org.simantics.acorn.exception.InvalidHeadStateException;
19 import org.simantics.databoard.Bindings;
20 import org.simantics.databoard.binding.mutable.MutableVariant;
21 import org.simantics.databoard.util.binary.BinaryMemory;
22 import org.slf4j.Logger;
23 import org.slf4j.LoggerFactory;
24
25 public class MainState implements Serializable {
26
27     private static final long serialVersionUID = 6237383147637270225L;
28
29     private static final Logger LOGGER = LoggerFactory.getLogger(MainState.class);
30
31     public static final String MAIN_STATE = "main.state";
32
33     public int headDir;
34
35     public MainState() {
36         this.headDir = 0;
37     }
38
39     private MainState(int headDir) {
40         this.headDir = headDir;
41     }
42
43     public static MainState load(Path directory, Runnable rollbackCallback) throws IOException {
44         Files.createDirectories(directory);
45         Path mainState = directory.resolve(MAIN_STATE);
46         try {
47             MainState state = (MainState) org.simantics.databoard.Files.readFile(
48                     mainState.toFile(),
49                     Bindings.getBindingUnchecked(MainState.class));
50             int latestRevision = state.headDir - 1;
51             try {
52                 HeadState.validateHeadStateIntegrity(directory.resolve(latestRevision + "/" + HeadState.HEAD_STATE));
53                 archiveRevisionDirectories(directory, latestRevision, rollbackCallback);
54                 return state;
55             } catch (InvalidHeadStateException e) {
56                 LOGGER.warn("Failed to start database from revision " + latestRevision + " stored in " + mainState + ". " + HeadState.HEAD_STATE + " is invalid.");
57                 return rollback(directory, rollbackCallback);
58             } catch (FileNotFoundException e) {
59                 LOGGER.warn("Failed to start database from revision " + latestRevision + " stored in " + mainState + ". Revision does not contain " + HeadState.HEAD_STATE + ".");
60                 return rollback(directory, rollbackCallback);
61             }
62         } catch (FileNotFoundException e) {
63             // The database may also be totally empty at this point
64             if (!listRevisionDirs(directory, true, MainState::isInteger).isEmpty())
65                 return new MainState(0);
66
67             LOGGER.warn("Unclean exit detected, " + mainState + " not found. Initiating automatic rollback.");
68             return rollback(directory, rollbackCallback);
69         } catch (Exception e) {
70             LOGGER.warn("Unclean exit detected. Initiating automatic rollback.", e);
71             return rollback(directory, rollbackCallback);
72         } finally {
73             Files.deleteIfExists(mainState);
74         }
75     }
76
77     private static MainState rollback(Path directory, Runnable rollbackCallback) throws IOException {
78         LOGGER.warn("Database rollback initiated for " + directory);
79         rollbackCallback.run();
80         Path latest = findNewHeadStateDir(directory, rollbackCallback);
81         int latestRevision = latest != null ? safeParseInt(-1, latest) : -1;
82         // +1 because we want to return the next head version to use,
83         // not the latest existing version.
84         MainState state = new MainState( latestRevision + 1 );
85         archiveRevisionDirectories(directory, latestRevision, rollbackCallback);
86         LOGGER.warn("Database rollback completed. Restarting database from revision " + latest);
87         return state;
88     }
89
90     private byte[] toByteArray() throws IOException {
91         try (BinaryMemory rf = new BinaryMemory(4096)) {
92             Bindings.getSerializerUnchecked(Bindings.VARIANT).serialize(rf, MutableVariant.ofInstance(this));
93             return rf.toByteBuffer().array();
94         }
95     }
96
97     public void save(Path directory) throws IOException {
98         Path f = directory.resolve(MAIN_STATE);
99         Files.write(f, toByteArray());
100         FileIO.syncPath(f);
101     }
102
103     private static int safeParseInt(int defaultValue, Path p) {
104         try {
105             return Integer.parseInt(p.getFileName().toString());
106         } catch (NumberFormatException e) {
107             return defaultValue;
108         }
109     }
110
111     private static boolean isInteger(Path p) {
112         return safeParseInt(Integer.MIN_VALUE, p) != Integer.MIN_VALUE;
113     }
114
115     private static Predicate<Path> isGreaterThan(int i) {
116         return p -> {
117             int pi = safeParseInt(Integer.MIN_VALUE, p);
118             return pi != Integer.MIN_VALUE && pi > i;
119         };
120     }
121
122     /**
123      *  
124      * @param directory
125      * @param callback 
126      * @return
127      * @throws IOException
128      */
129     private static Path findNewHeadStateDir(Path directory, Runnable rollbackCallback) throws IOException {
130         for (Path last : listRevisionDirs(directory, true, MainState::isInteger)) {
131             try {
132                 HeadState.validateHeadStateIntegrity(last.resolve(HeadState.HEAD_STATE));
133                 return last;
134             } catch (IOException | InvalidHeadStateException e) {
135                 // Cleanup is done in {@link cleanRevisionDirectories} method
136                 rollbackCallback.run();
137             }
138         }
139         return null;
140     }
141
142     private static void archiveRevisionDirectories(Path directory, int greaterThanRevision, Runnable rollbackCallback) throws IOException {
143         List<Path> reverseSortedPaths = listRevisionDirs(directory, true, isGreaterThan(greaterThanRevision));
144         if (reverseSortedPaths.isEmpty())
145             return;
146
147         // If none of the revisions to be archived are actually committed revisions
148         // then just delete them. Commitment is indicated by the head.state file.
149         if (!anyContainsHeadState(reverseSortedPaths)) {
150             for (Path p : reverseSortedPaths) {
151                 deleteAll(p);
152                 LOGGER.info("Removed useless working folder " + p);
153             }
154             return;
155         }
156
157         // Some kind of rollback is being performed. There is a possibility that
158         // indexes and virtual graphs are out of sync with the persistent database.
159         rollbackCallback.run();
160
161         Path recoveryFolder = getRecoveryFolder(directory);
162         Files.createDirectories(recoveryFolder);
163         LOGGER.info("Created new database recovery folder " + recoveryFolder);
164         for (Path p : reverseSortedPaths) {
165             Files.move(p, recoveryFolder.resolve(p.getFileName().toString()));
166             LOGGER.info("Archived revision " + p + " in recovery folder " + recoveryFolder);
167         }
168     }
169
170     private static boolean anyContainsHeadState(List<Path> paths) {
171         for (Path p : paths)
172             if (Files.exists(p.resolve(HeadState.HEAD_STATE)))
173                 return true;
174         return false;
175     }
176
177     @SafeVarargs
178     private static List<Path> listRevisionDirs(Path directory, boolean descending, Predicate<Path>... filters) throws IOException {
179         int coef = descending ? -1 : 1;
180         try (Stream<Path> s = Files.walk(directory, 1)) {
181             Stream<Path> fs = s.filter(p -> !p.equals(directory));
182             for (Predicate<Path> p : filters)
183                 fs = fs.filter(p);
184             return fs.filter(Files::isDirectory)
185                     .sorted((p1, p2) -> coef * Integer.compare(Integer.parseInt(p1.getFileName().toString()),
186                                                                Integer.parseInt(p2.getFileName().toString())))
187                     .collect(Collectors.toList());
188         }
189     }
190
191     private static void deleteAll(Path dir) throws IOException {
192         Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
193             @Override
194             public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
195                 Files.delete(file);
196                 return FileVisitResult.CONTINUE;
197             }
198             @Override
199             public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
200                 Files.delete(dir);
201                 return FileVisitResult.CONTINUE;
202             }
203         });
204     }
205
206     private static final DateTimeFormatter RECOVERY_DIR_FORMAT = DateTimeFormatter.ofPattern("yyyy-M-d_HH-mm-ss");
207
208     private static Path getRecoveryFolder(Path directory) {
209         return findNonexistentDir(
210                 directory.resolve("recovery"),
211                 RECOVERY_DIR_FORMAT.format(ZonedDateTime.now()));
212     }
213
214     private static Path findNonexistentDir(Path inDirectory, String prefix) {
215         for (int i = 0;; ++i) {
216             Path dir = inDirectory.resolve(i == 0 ? prefix : prefix + "-" + i);
217             if (Files.notExists(dir))
218                 return dir;
219         }
220     }
221
222 }