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");
   }
 }