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