Periodically clean outdated session cache files

When a user logs out, the corresponding web session cache file is
deleted. When the web session expires, however, the file remains in the
cache directory. In a busy Gerrit server using this plugin, the size of
the cache directory can reach hundreds of megabytes within a few months.

Remove outdated session cache files in a periodic way. Cache files are
considered as outdated when its expiration date has passed.

Change-Id: Id195f2b04ef4b323ce5c5a1c024e62ebeea39720
diff --git a/BUCK b/BUCK
index 7f1487c..2260abb 100644
--- a/BUCK
+++ b/BUCK
@@ -14,6 +14,7 @@
   resources = RESOURCES,
   manifest_entries = [
     'Gerrit-PluginName: websession-flatfile',
+    'Gerrit-Module: com.googlesource.gerrit.plugins.websession.flatfile.Module',
     'Gerrit-HttpModule: com.googlesource.gerrit.plugins.websession.flatfile.FlatFileWebSession$Module',
     'Implementation-Title: Flat file WebSession',
     'Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile',
diff --git a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/CleanupInterval.java b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/CleanupInterval.java
new file mode 100644
index 0000000..9874881
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/CleanupInterval.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 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.websession.flatfile;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CleanupInterval {
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSession.java b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSession.java
index 75716eb..e9a2b70 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSession.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSession.java
@@ -14,7 +14,6 @@
 
 package com.googlesource.gerrit.plugins.websession.flatfile;
 
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.annotations.RootRelative;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.CacheBasedWebSession;
@@ -23,19 +22,12 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser.RequestFactory;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
 import com.google.inject.servlet.RequestScoped;
 import com.google.inject.servlet.ServletScopes;
 
-import java.nio.file.Path;
-import java.nio.file.Paths;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -49,15 +41,6 @@
       DynamicItem.bind(binder(), WebSession.class)
           .to(FlatFileWebSession.class).in(RequestScoped.class);
     }
-
-    @Provides
-    @Singleton
-    @WebSessionDir
-    Path getWebSessionDir(SitePaths site, PluginConfigFactory cfg,
-        @PluginName String pluginName) {
-      return Paths.get(cfg.getFromGerritConfig(pluginName).getString(
-          "directory", site.site_path + "/websessions"));
-    }
   }
 
   @Inject
diff --git a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCache.java b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCache.java
index 8d12bb2..50acdf0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCache.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCache.java
@@ -23,6 +23,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.joda.time.DateTime;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -80,7 +81,13 @@
 
   @Override
   public void cleanUp() {
-    // do nothing
+    for (Path path : listFiles()) {
+      Val val = readFile(path);
+      DateTime expires = new DateTime(val.getExpiresAt());
+      if (expires.isBefore(new DateTime())) {
+        deleteFile(path);
+      }
+    }
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheCleaner.java b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheCleaner.java
new file mode 100644
index 0000000..c6c89a6
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheCleaner.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2015 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.websession.flatfile;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+
+class FlatFileWebSessionCacheCleaner implements Runnable {
+
+  static class CleanerLifecycle implements LifecycleListener {
+    private static final int INITIAL_DELAY_MS = 1000;
+    private final WorkQueue queue;
+    private final FlatFileWebSessionCacheCleaner cleaner;
+    private final long cleanupInterval;
+
+    @Inject
+    CleanerLifecycle(
+        WorkQueue queue,
+        FlatFileWebSessionCacheCleaner cleaner,
+        @CleanupInterval long cleanupInterval) {
+      this.queue = queue;
+      this.cleaner = cleaner;
+      this.cleanupInterval = cleanupInterval;
+    }
+
+    @Override
+    public void start() {
+      queue.getDefaultQueue().scheduleAtFixedRate(cleaner, INITIAL_DELAY_MS,
+          cleanupInterval, MILLISECONDS);
+    }
+
+    @Override
+    public void stop() {
+    }
+  }
+
+  private FlatFileWebSessionCache flatFileWebSessionCache;
+
+  @Inject
+  FlatFileWebSessionCacheCleaner(FlatFileWebSessionCache flatFileWebSessionCache) {
+    this.flatFileWebSessionCache = flatFileWebSessionCache;
+  }
+
+  @Override
+  public void run() {
+    flatFileWebSessionCache.cleanUp();
+  }
+
+  @Override
+  public String toString() {
+    return "FlatFile WebSession Cleaner";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/Module.java b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/Module.java
new file mode 100644
index 0000000..a5ea0c3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/websession/flatfile/Module.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2015 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.websession.flatfile;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+import com.googlesource.gerrit.plugins.websession.flatfile.FlatFileWebSessionCacheCleaner.CleanerLifecycle;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+public class Module extends LifecycleModule {
+
+  private static final int DEFAULT_CLEANUP_INTERVAL = 24;
+
+  @Override
+  protected void configure() {
+    listener().to(CleanerLifecycle.class);
+  }
+
+  @Provides
+  @Singleton
+  @WebSessionDir
+  Path getWebSessionDir(SitePaths site, PluginConfigFactory cfg,
+      @PluginName String pluginName) {
+    return Paths.get(cfg.getFromGerritConfig(pluginName).getString("directory",
+        site.site_path + "/websessions"));
+  }
+
+  @Provides
+  @Singleton
+  @CleanupInterval
+  Long getCleanupInterval(PluginConfigFactory cfg, @PluginName String pluginName) {
+    String fromConfig =
+        Strings.nullToEmpty(cfg.getFromGerritConfig(pluginName).getString(
+            "cleanupInterval"));
+    return HOURS.toMillis(ConfigUtil.getTimeUnit(fromConfig,
+        DEFAULT_CLEANUP_INTERVAL, HOURS));
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 09a49d5..cc5e71f 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -8,8 +8,10 @@
 main Gerrit config file: $site_dir/etc/gerrit.config.  This
 location defaults to $site_dir/websessions.
 
+```
   [plugin "@PLUGIN@"]
     directory = <disk_cache_directory>
+
   # NOTE: <disk_cache_directory> can be any location on the
   # shared filesystem that can be accessed by all servers,
   # and in which rename operations are atomic and allow
@@ -19,6 +21,33 @@
 Reload the plugin on each master for the changes to take
 effect.
 
+The plugin periodically cleans up the cache directory, deleting
+files corresponding to expired sessions. The frequency of this
+operation can be specified in the configuration. For example:
+
+```
+  [plugin "@PLUGIN@"]
+    cleanupInterval = 1h
+```
+
+indicates the cleanup operation to be triggered every hour.
+
+Values should use common time unit suffixes to express their setting:
+
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
+
+If a time unit suffix is not specified, `hours` is assumed.
+
+If 'cleanupInterval' is not present in the configuration, the
+cleanup operation is triggered every 24 hours.
+
+
 SEE ALSO
 --------
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheTest.java b/src/test/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheTest.java
index 1eb7200..038135a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/websession/flatfile/FlatFileWebSessionCacheTest.java
@@ -84,11 +84,19 @@
   }
 
   @Test
+  public void cleanUpTest() throws Exception {
+    loadExistingKeyToCacheDir();
+    flatFileWebSessionCache.cleanUp();
+    assertThat(isDirEmpty(dir)).isTrue();
+  }
+
+  @Test
   public void getAllPresentTest() throws Exception {
     Files.createFile(dir.resolve(key));
     loadExistingKeyToCacheDir();
     List<String> keys = Arrays.asList(new String[] {key, existingKey});
-    assertThat(flatFileWebSessionCache.getAllPresent(keys)).containsKey(existingKey);
+    assertThat(flatFileWebSessionCache.getAllPresent(keys))
+        .containsKey(existingKey);
   }
 
   @Test
@@ -116,13 +124,15 @@
         return null;
       }
     }
-    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader())).isNull();
+    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader()))
+        .isNull();
 
     loadExistingKeyToCacheDir();
-    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader())).isNotNull();
+    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader()))
+        .isNotNull();
   }
 
-  @Test(expected=ExecutionException.class)
+  @Test(expected = ExecutionException.class)
   public void getTestCallableThrowsException() throws Exception {
     class ValueLoader implements Callable<Val> {
       @Override
@@ -130,7 +140,8 @@
         throw new Exception();
       }
     }
-    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader())).isNull();
+    assertThat(flatFileWebSessionCache.get(existingKey, new ValueLoader()))
+        .isNull();
   }
 
   @Test
@@ -166,10 +177,10 @@
   @Test
   public void putTest() throws Exception {
     loadExistingKeyToCacheDir();
-     Val val = flatFileWebSessionCache.getIfPresent(existingKey);
-     String newKey = "abcde12345";
-     flatFileWebSessionCache.put(newKey, val);
-     assertThat(flatFileWebSessionCache.getIfPresent(newKey)).isNotNull();
+    Val val = flatFileWebSessionCache.getIfPresent(existingKey);
+    String newKey = "abcde12345";
+    flatFileWebSessionCache.put(newKey, val);
+    assertThat(flatFileWebSessionCache.getIfPresent(newKey)).isNotNull();
   }
 
   @Test