Use a multi-master safe persistent store ID Initialize the FsStore Id with a transaction. This is a first (little) step towards making the store MM safe. Change-Id: I3e4f1fac950ad6de43e7ff81e2b13b884d5c3557
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FileValue.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FileValue.java new file mode 100644 index 0000000..2572c31 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FileValue.java
@@ -0,0 +1,97 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Helper file for serialzing and storing a single type in a file */ +public class FileValue<T> { + protected final Path path; + protected Serializer<T> serializer; + + /** + * Use this constructor to use a builtin serializer (String or Long), and be sure to call init(T) + * with a value of your type for the serializer auto identification to happen. + */ + public FileValue(Path path) { + this(path, (Serializer<T>) null); + } + + public FileValue(Path path, Serializer<T> serializer) { + this.path = path; + this.serializer = serializer; + } + + /** Should be called by subclasses when initializing their file to a value. */ + protected void init(T init) { + initSerializer(init); + } + + /** + * Auto setup the serializer based on the type used to initialize the class. + * + * <p>Must be called with a supported type before use if a serializer has been set manualy. Safe + * to call if the Serializer was already set. + */ + protected void initSerializer(T init) { + if (serializer == null) { + if (init instanceof String) { + serializer = (Serializer<T>) new Serializer.String(); + } else if (init instanceof Long) { + serializer = (Serializer<T>) new Serializer.Long(); + } + } + } + + /** get (read) the unserialized object from the file with retries for stale file handles (NFS). */ + public T spinGet(long maxTries) throws IOException { + for (long i = 0; i < maxTries; i++) { + try { + return get(); + } catch (IOException e) { + Nfs.throwIfNotStaleFileHandle(e); + } + } + throw new IOException( + "Cannot read file " + path + " after " + maxTries + " Stale file handle tries."); + } + + /** get (read) the unserialized object from the file */ + protected T get() throws IOException { + return serializer.fromString(read()); + } + + /** The lowest level raw String read of the file */ + protected String read() throws IOException { + return Fs.readFile(path); + } + + /** Serialize object to given tmp file in preparation to call update() */ + protected void prepareT(Path tmp, T t) throws IOException { + prepare(tmp, serializer.fromGeneric(t)); + } + + /** Atmoically update (replace) file with src file. */ + protected boolean update(Path src) throws IOException { + return Fs.tryAtomicMove(src, path); + } + + /** Low level raw string write to given tmp file in preparation to call update(). */ + protected static void prepare(Path tmp, String s) throws IOException { + Files.write(tmp, s.getBytes()); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Fs.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Fs.java index 6c586b8..8697198 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Fs.java +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Fs.java
@@ -17,15 +17,29 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.TimeUnit; /** Some Filesystem utilities */ public class Fs { + /** Try to move a file/dir atomically. Do NOT throw IOExceptions. */ + public static boolean tryAtomicMove(Path src, Path dst) { + try { + Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE); + return true; + } catch (IOException e) { + return false; + } + } + /** Try to recursively delete a dir. Do NOT throw IOExceptions. */ public static void tryRecursiveDelete(Path dir) { try { @@ -49,6 +63,36 @@ } } + /** + * Try to recursively delete entries, up to max count, in a dir older than expiry. Do NOT throw + * IOExceptions. + * + * @return whether all entries were deleted + */ + public static boolean tryRecursiveDeleteEntriesOlderThan(Path dir, FileTime expiry, int max) { + try (DirectoryStream<Path> dirEntries = Files.newDirectoryStream(dir)) { + for (Path path : dirEntries) { + if (isOlderThan(path, expiry)) { + if (max-- < 1) { + return false; + } + tryRecursiveDelete(path); + } + } + } catch (IOException e) { + } + return true; + } + + /** Is an entry older than expiry. Do NOT throw IOExceptions. */ + public static boolean isOlderThan(Path path, FileTime expiry) { + try { + return expiry.compareTo(Files.getLastModifiedTime(path)) > 0; + } catch (IOException e) { + return false; + } + } + /** Try to delete a path. Do NOT throw IOExceptions. */ public static void tryDelete(Path path) { try { @@ -98,4 +142,20 @@ return Files.createDirectories(Files.readSymbolicLink(path)); } } + + /** Get the reparented path as if a path were moved to a new location */ + public static Path reparent(Path src, Path dst) { + return dst.resolve(basename(src)); + } + + /** Get the tail of a path (similar to the unix basename command) */ + public static Path basename(Path p) { + return p.getName(p.getNameCount() - 1); + } + + /** Get a FileTime indicating a certain amount of time beforehand (ago). */ + public static FileTime getFileTimeAgo(long ago, TimeUnit unit) { + long ms = TimeUnit.MILLISECONDS.convert(ago, unit); + return FileTime.fromMillis(System.currentTimeMillis() - ms); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsId.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsId.java new file mode 100644 index 0000000..5658d79 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsId.java
@@ -0,0 +1,68 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +/** + * Store a non changing String ID to a file. + * + * <p>This class is multi node/process (Multi-Master) safe. The ID will only ever get written once, + * the first writer will win. + */ +public class FsId extends FileValue<String> { + public static final Path VALUE = Paths.get("value"); + + public static class BasePaths extends FsTransaction.BasePaths { + public final Path valueDir; + + public BasePaths(Path base) { + super(base); + valueDir = base.resolve(VALUE); + } + } + + protected static class Builder extends FsTransaction.Builder { + public Builder(BasePaths paths, String value) throws IOException { + super(paths); + FileValue.prepare(dir.resolve(VALUE), value); // build/<tmp>/value(val) + } + } + + protected final BasePaths paths; + + public FsId(Path base) { + super(base.resolve(VALUE).resolve(VALUE)); // value/value(val) + this.paths = new BasePaths(base); + } + + public void initFs() throws IOException { + initFs(UUID.randomUUID().toString()); + } + + public void initFs(String init) throws IOException { + super.init(init); + while (!Files.exists(path)) { + try (Builder b = new Builder(paths, init)) { + // mv build/<tmp>/value(val) value/value(val) + Fs.tryAtomicMove(b.dir, paths.valueDir); + } + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsStore.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsStore.java index 25264a6..40e0401 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsStore.java +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsStore.java
@@ -54,20 +54,6 @@ } } - public static class StringFsValue extends FsValue<String> { - public StringFsValue(Path path) { - super(path); - } - - public String get() throws IOException { - return load(); - } - - public void set(String value) throws IOException { - store(value + "\n"); - } - } - public static class FsSequence extends FsValue<Long> { public FsSequence(Path path) { super(path); @@ -114,18 +100,18 @@ } protected static class Stores { - final StringFsValue uuid; + final FsId uuid; final FsSequence head; final FsSequence tail; public Stores(BasePaths bases) { - uuid = new StringFsValue(bases.uuid); + uuid = new FsId(bases.uuid); head = new FsSequence(bases.head); tail = new FsSequence(bases.tail); } public void initFs() throws IOException { - uuid.initFs(UUID.randomUUID().toString()); + uuid.initFs(); head.initFs((long) 0); tail.initFs((long) 1); } @@ -137,7 +123,7 @@ @Inject public FsStore(SitePaths site) throws IOException { - this(site.data_dir.toPath().resolve("plugin").resolve("events").resolve("fstore-v1")); + this(site.data_dir.toPath().resolve("plugin").resolve("events").resolve("fstore-v1.1")); } public FsStore(Path base) throws IOException {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsTransaction.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsTransaction.java new file mode 100644 index 0000000..d2bf8cd --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsTransaction.java
@@ -0,0 +1,135 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.TimeUnit; + +public class FsTransaction { + /** + * A class to keep track of scratch pads to safely build proposals, and to safely delete them + * during cleanup. + * + * <p>The first assumption is that unique dirs under the build dir will be used for building, and + * that these may be deleted at any time to keep the filesystem clean under the assumption that + * they may be stale. The contract however, is that all deleting must be done by first moving the + * toplevel dir to the delete directory. This ensures that the processes creating entries under + * the build dir will always have their entries intact or non-existing, but never partially what + * they expect. + * + * <p>The next assumption is that all entries under the build dir are not only safe to delete at + * any time, but that they should be deleted by helping processes to ensure that interrupted + * processes do not lead to entry build up in the filesystem. + */ + public static class BasePaths { + public final Path base; + public final Path build; + public final Path delete; + + // Stale entries should be designed to be rare, and only happen during + // unclean shutdowns. + public long cleanInterval = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); + public int maxDelete = 5; // keep low to not slowdown current update much. + protected long lastClean; + + public BasePaths(Path base) { + this.base = base; + build = base.resolve("build"); + delete = base.resolve("delete"); + } + + public void autoClean() { + if (needsClean()) { + FileTime expiry = Fs.getFileTimeAgo(1, TimeUnit.DAYS); + // maxDelete spreads a large cleaning burden over multiple updates + if (clean(expiry, maxDelete)) { + lastClean = System.currentTimeMillis(); + } + } + } + + /** Clean up to 'max' expired (presumably stale) entries */ + public boolean clean(FileTime expiry, int max) { + try { + return Fs.tryRecursiveDeleteEntriesOlderThan(delete, expiry, max) + || renameAndDeleteEntriesOlderThan(build, delete, expiry, max); + } catch (IOException e) { + // If we knew if it was a repeat offender, we could consider logging it. + return true; // Don't keep retrying failures. + } + } + + protected boolean needsClean() { + return System.currentTimeMillis() - cleanInterval > lastClean; + } + } + + /** A tempdirectory builder that gets automatically cleaned up. */ + protected static class Builder implements AutoCloseable { + public final BasePaths paths; + public final Path dir; + + public Builder(BasePaths paths) throws IOException { + this.paths = paths; + Files.createDirectories(paths.build); + Files.createDirectories(paths.delete); + dir = Files.createTempDirectory(paths.build, null); + } + + public void close() throws IOException { + FsTransaction.renameAndDelete(dir, paths.delete); + paths.autoClean(); + } + } + + /** + * Used to atomically delete a directory tree. Avoids name collisions with other processes + * potentially using the same source name directory. Collisions could prevent the move to the + * delete directory from succeeding. + */ + public static void renameAndDelete(Path src, Path del) throws IOException { + if (Files.exists(src)) { + Path tmp = Files.createTempDirectory(del, null); + Path reparented = Fs.reparent(src, tmp); + Fs.tryAtomicMove(src, reparented); + Fs.tryRecursiveDelete(tmp); + } + } + + /** + * Used to atomically delete entries in a directory tree older than expiry, up to max count. Do + * NOT throw IOExceptions. + * + * @return whether all entries were deleted + */ + public static boolean renameAndDeleteEntriesOlderThan( + Path dir, Path del, FileTime expiry, int max) throws IOException { + try (DirectoryStream<Path> dirEntries = Files.newDirectoryStream(dir)) { + for (Path path : dirEntries) { + if (expiry.compareTo(Files.getLastModifiedTime(path)) > 0) { + if (max-- < 1) { + return false; + } + renameAndDelete(path, del); + } + } + return true; + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java new file mode 100644 index 0000000..99c33af --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java
@@ -0,0 +1,56 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +import java.io.IOException; +import java.util.Locale; + +/** Some NFS utilities */ +public class Nfs { + /** + * Determine if a throwable or a cause in its causal chain is a Stale NFS + * File Handle + * + * @param throwable + * @return a boolean true if the throwable or a cause in its causal chain is + * a Stale NFS File Handle + */ + public static boolean isStaleFileHandleInCausalChain(Throwable throwable) { + while (throwable != null) { + if (throwable instanceof IOException && isStaleFileHandle((IOException) throwable)) { + return true; + } + throwable = throwable.getCause(); + } + return false; + } + + /** + * Determine if an IOException is a Stale NFS File Handle + * + * @param ioe + * @return a boolean true if the IOException is a Stale NFS FIle Handle + */ + public static boolean isStaleFileHandle(IOException ioe) { + String msg = ioe.getMessage(); + return msg != null && msg.toLowerCase(Locale.ROOT).matches(".*stale .*file .*handle.*"); + } + + public static <T extends Throwable> void throwIfNotStaleFileHandle(T e) throws T { + if (!isStaleFileHandleInCausalChain(e)) { + throw e; + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Serializer.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Serializer.java new file mode 100644 index 0000000..83864f2 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Serializer.java
@@ -0,0 +1,48 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +/** Simple serializers for writing data types to Strings. */ +public interface Serializer<G> { + public static class String implements Serializer<java.lang.String> { + @Override + public java.lang.String fromString(java.lang.String s) { + return s; + } + + @Override + public java.lang.String fromGeneric(java.lang.String g) { + return g; + } + } + + public static class Long implements Serializer<java.lang.Long> { + @Override + public java.lang.Long fromString(java.lang.String s) { + return s == null ? null : java.lang.Long.parseLong(s); + } + + @Override + public java.lang.String fromGeneric(java.lang.Long g) { + return g == null ? null : java.lang.Long.toString(g) + "\n"; + } + } + + /* ----- Interface starts here ----- */ + + G fromString(java.lang.String s); + + java.lang.String fromGeneric(G g); +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsIdTest.java b/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsIdTest.java new file mode 100644 index 0000000..41f2e92 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsIdTest.java
@@ -0,0 +1,64 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.events.fsstore; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import junit.framework.TestCase; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class FsIdTest extends TestCase { + private static String dir = "events-FsId"; + private static Path base; + private FsId val; + + @Override + @Before + public void setUp() throws IOException { + if (base == null) { + base = Files.createTempDirectory(dir); + } + val = new FsId(base); + val.initFs("init"); + } + + @After + public void tearDown() throws IOException { + Fs.tryRecursiveDelete(base); + } + + @Test + public void testGetInit() throws IOException { + assertEquals("init", val.get()); + } + + @Test + public void testReInit() throws IOException { + val.initFs("init2"); + assertEquals("init", val.get()); + } + + @Test + public void testGetInitUUID() throws IOException { + tearDown(); + val.initFs(); + String id = val.get(); + val.initFs(); + assertEquals(id, val.get()); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsStoreTest.java b/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsStoreTest.java index 8da8fba..ddb2c3e 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsStoreTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/events/fsstore/FsStoreTest.java
@@ -150,7 +150,7 @@ } } - public void verify(String id, long head) throws Exception { + public boolean verify(String id, long head) throws Exception { Set<Long> found = new HashSet<Long>(); long stop = store.getHead(); long mine = 1; @@ -178,10 +178,13 @@ report("missing", i, -1); } } - reportTotal("duplicate"); - reportTotal("out of order"); - reportTotal("missing"); - reportTotal("gap"); + + boolean error = false; + error |= reportTotal("duplicate"); + error |= reportTotal("out of order"); + error |= reportTotal("missing"); + error |= reportTotal("gap"); + return error; } private void report(String type, long n, long i) { @@ -193,11 +196,13 @@ reported.put(type, ++cnt); } - private void reportTotal(String type) { + private boolean reportTotal(String type) { Long cnt = reported.get(type); if (cnt != null) { System.out.println("Total " + type + "s: " + cnt); + return true; } + return false; } /** @@ -211,6 +216,10 @@ * com.googlesource.gerrit.plugins.events.fsstore.FsStoreTest \ [dir [count [store-id]]] * * <p>Note: if you do not specify <dir>, it will create a directory under /tmp + * + * <p>Performance: NFS(Fast,Lowlatency,SSDs), 1 worker, count=1000, ~6s ~6ms/event + * + * <p>Local(spinning) 5 workers count=100K 11m38.512s find events|wc -l 2s rm -rf 10s */ public static void main(String[] argv) throws Exception { if (argv.length > 0) { @@ -231,6 +240,10 @@ t.setUp(); long head = t.store.getHead(); t.count(id); - t.verify(id, head); + if (t.verify(id, head)) { + System.out.println("\nFAIL"); + System.exit(1); + } + System.out.println("\nPASS"); } }