Limit how big buck-cache can grow.

Summary: This has bugged me for a while.
diff --git a/src/com/facebook/buck/cli/BUCK b/src/com/facebook/buck/cli/BUCK
index c74794e..b7e56aa 100644
--- a/src/com/facebook/buck/cli/BUCK
+++ b/src/com/facebook/buck/cli/BUCK
@@ -57,6 +57,7 @@
     '//src/com/facebook/buck/util:io',
     '//src/com/facebook/buck/util:network',
     '//src/com/facebook/buck/util:util',
+    '//src/com/facebook/buck/util/unit:unit',
     '//src/com/facebook/buck/util/environment:environment',
     '//src/com/facebook/buck/timing:timing',
     '//third-party/java/astyanax:astyanax-cassandra',
diff --git a/src/com/facebook/buck/cli/BuckConfig.java b/src/com/facebook/buck/cli/BuckConfig.java
index 5990222..b6c4edc 100644
--- a/src/com/facebook/buck/cli/BuckConfig.java
+++ b/src/com/facebook/buck/cli/BuckConfig.java
@@ -35,6 +35,7 @@
 import com.facebook.buck.util.MorePaths;
 import com.facebook.buck.util.ProjectFilesystem;
 import com.facebook.buck.util.environment.Platform;
+import com.facebook.buck.util.unit.SizeUnit;
 import com.google.common.annotations.Beta;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Charsets;
@@ -327,11 +328,11 @@
     // Normalize paths in order to eliminate trailing '/' characters and whatnot.
     return ImmutableSet.<String>builder().addAll(Iterables.transform(builder.build(),
         new Function<String, String>() {
-      @Override
-      public String apply(String path) {
-        return MorePaths.newPathInstance(path).toString();
-      }
-    })).build();
+          @Override
+          public String apply(String path) {
+            return MorePaths.newPathInstance(path).toString();
+          }
+        })).build();
   }
 
   public ImmutableSet<Pattern> getTempFilePatterns() {
@@ -515,7 +516,9 @@
       for (String mode : modes) {
         switch (ArtifactCacheNames.valueOf(mode)) {
         case dir:
-          builder.add(createDirArtifactCache());
+          ArtifactCache dirArtifactCache = createDirArtifactCache();
+          buckEventBus.register(dirArtifactCache);
+          builder.add(dirArtifactCache);
           break;
         case cassandra:
           ArtifactCache cassandraArtifactCache = createCassandraArtifactCache(buckEventBus);
@@ -551,11 +554,20 @@
     return projectFilesystem.getPathRelativizer().apply(cacheDir);
   }
 
+  public Optional<Long> getCacheDirMaxSizeBytes() {
+    return getValue("cache", "dir_max_size").transform(new Function<String, Long>() {
+      @Override
+      public Long apply(String input) {
+        return SizeUnit.parseBytes(input);
+      }
+    });
+  }
+
   private ArtifactCache createDirArtifactCache() {
     Path cacheDir = getCacheDir();
     File dir = cacheDir.toFile();
     try {
-      return new DirArtifactCache(dir);
+      return new DirArtifactCache(dir, getCacheDirMaxSizeBytes());
     } catch (IOException e) {
       throw new HumanReadableException("Failure initializing artifact cache directory: %s", dir);
     }
diff --git a/src/com/facebook/buck/rules/BUCK b/src/com/facebook/buck/rules/BUCK
index ce4dc87..d9ee695 100644
--- a/src/com/facebook/buck/rules/BUCK
+++ b/src/com/facebook/buck/rules/BUCK
@@ -133,6 +133,7 @@
     '//src/com/facebook/buck/step/fs:fs',
     '//src/com/facebook/buck/test:test',
     '//src/com/facebook/buck/util:constants',
+    '//src/com/facebook/buck/util/collect:collect',
     '//src/com/facebook/buck/util:exceptions',
     '//src/com/facebook/buck/util:io',
     '//src/com/facebook/buck/util:network',
diff --git a/src/com/facebook/buck/rules/DirArtifactCache.java b/src/com/facebook/buck/rules/DirArtifactCache.java
index e244e25..db4f867 100644
--- a/src/com/facebook/buck/rules/DirArtifactCache.java
+++ b/src/com/facebook/buck/rules/DirArtifactCache.java
@@ -18,22 +18,63 @@
 
 import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
 
+import com.facebook.buck.util.collect.ArrayIterable;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.eventbus.Subscribe;
 
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 public class DirArtifactCache implements ArtifactCache {
+  private class FileAccessedEntry {
+    public final File file;
+    public final FileTime lastAccessTime;
+
+    public File getFile() {
+      return file;
+    }
+
+    public FileTime getLastAccessTime() {
+      return lastAccessTime;
+    }
+
+    private FileAccessedEntry(File file, FileTime lastAccessTime) {
+      this.file = file;
+      this.lastAccessTime = lastAccessTime;
+    }
+  }
+
+
   private final static Logger logger = Logger.getLogger(DirArtifactCache.class.getName());
 
-  private final File cacheDir;
+  /**
+   * Sorts by the lastAccessTime in descending order (more recently accessed files are first).
+   */
+  private final static Comparator<FileAccessedEntry> SORT_BY_LAST_ACCESSED_TIME_DESC =
+      new Comparator<FileAccessedEntry>() {
+    @Override
+    public int compare(FileAccessedEntry a, FileAccessedEntry b) {
+      return b.getLastAccessTime().compareTo(a.getLastAccessTime());
+    }
+  };
 
-  public DirArtifactCache(File cacheDir) throws IOException {
+  private final File cacheDir;
+  private final Optional<Long> maxCacheSizeBytes;
+
+  public DirArtifactCache(File cacheDir, Optional<Long> maxCacheSizeBytes) throws IOException {
     this.cacheDir = Preconditions.checkNotNull(cacheDir);
+    this.maxCacheSizeBytes = Preconditions.checkNotNull(maxCacheSizeBytes);
     Files.createDirectories(cacheDir.toPath());
   }
 
@@ -92,4 +133,59 @@
   public boolean isStoreSupported() {
     return true;
   }
+
+  @Subscribe
+  public synchronized void buildFinished(BuildEvent.Finished finished) {
+    deleteOldFiles();
+  }
+
+  /**
+   * Deletes files that haven't been accessed recently from the directory cache.
+   */
+  @VisibleForTesting
+  void deleteOldFiles() {
+    if (!maxCacheSizeBytes.isPresent()) {
+      return;
+    }
+    for (FileAccessedEntry fileAccessedEntry : findFilesToDelete()) {
+      try {
+        Files.deleteIfExists(fileAccessedEntry.getFile().toPath());
+      } catch (IOException e) {
+        // Eat any IOExceptions while attempting to clean up the cache directory.  If the file is
+        // now in use, we no longer want to delete it.
+        continue;
+      }
+    }
+  }
+
+  private Iterable<FileAccessedEntry> findFilesToDelete() {
+    Preconditions.checkState(maxCacheSizeBytes.isPresent());
+    long maxSizeBytes = maxCacheSizeBytes.get();
+
+    File[] artifacts = cacheDir.listFiles();
+    FileAccessedEntry[] fileAccessedEntries = new FileAccessedEntry[artifacts.length];
+    for (int i = 0; i < artifacts.length; ++i) {
+      FileTime lastAccess;
+      try {
+        lastAccess =
+            Files.readAttributes(artifacts[i].toPath(), BasicFileAttributes.class).lastAccessTime();
+      } catch (IOException e) {
+        lastAccess = FileTime.fromMillis(artifacts[i].lastModified());
+      }
+      fileAccessedEntries[i] = new FileAccessedEntry(artifacts[i], lastAccess);
+    }
+    Arrays.sort(fileAccessedEntries, SORT_BY_LAST_ACCESSED_TIME_DESC);
+
+    // Finds the first N from the list ordered by last access time who's combined size is less than
+    // maxCacheSizeBytes.
+    long currentSizeBytes = 0;
+    for (int i = 0; i < fileAccessedEntries.length; ++i) {
+      FileAccessedEntry file = fileAccessedEntries[i];
+      currentSizeBytes += file.getFile().length();
+      if (currentSizeBytes > maxSizeBytes) {
+        return ArrayIterable.of(fileAccessedEntries, i, fileAccessedEntries.length);
+      }
+    }
+    return ImmutableList.<FileAccessedEntry>of();
+  }
 }
diff --git a/src/com/facebook/buck/util/unit/BUCK b/src/com/facebook/buck/util/unit/BUCK
new file mode 100644
index 0000000..9628149
--- /dev/null
+++ b/src/com/facebook/buck/util/unit/BUCK
@@ -0,0 +1,10 @@
+java_library(
+  name = 'unit',
+  srcs = glob(['*.java']),
+  deps = [
+    '//lib:guava',
+  ],
+  visibility = [
+    'PUBLIC',
+  ],
+)
diff --git a/test/com/facebook/buck/rules/DirArtifactCacheTest.java b/test/com/facebook/buck/rules/DirArtifactCacheTest.java
index d191d93..46d1ad3 100644
--- a/test/com/facebook/buck/rules/DirArtifactCacheTest.java
+++ b/test/com/facebook/buck/rules/DirArtifactCacheTest.java
@@ -22,6 +22,8 @@
 
 import com.facebook.buck.util.NullFileHashCache;
 import com.google.common.base.Charsets;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.io.Files;
 
 import org.junit.Rule;
@@ -30,6 +32,7 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.attribute.FileTime;
 
 public class DirArtifactCacheTest {
   @Rule public TemporaryFolder tmpDir = new TemporaryFolder();
@@ -38,7 +41,7 @@
   public void testCacheCreation() throws IOException {
     File cacheDir = tmpDir.newFolder();
 
-    new DirArtifactCache(cacheDir);
+    new DirArtifactCache(cacheDir, /* maxCacheSizeBytes */ Optional.of(0L));
   }
 
   @Test
@@ -46,11 +49,13 @@
     File cacheDir = tmpDir.newFolder();
     File fileX = tmpDir.newFile("x");
 
-    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir);
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir,
+        /* maxCacheSizeBytes */ Optional.of(0L));
 
     Files.write("x", fileX, Charsets.UTF_8);
     InputRule inputRuleX = new InputRuleForTest(fileX);
-    RuleKey ruleKeyX = RuleKey.builder(inputRuleX, new NullFileHashCache()).build().getTotalRuleKey();
+    RuleKey ruleKeyX = RuleKey.builder(inputRuleX,
+        new NullFileHashCache()).build().getTotalRuleKey();
 
     assertEquals(CacheResult.MISS, dirArtifactCache.fetch(ruleKeyX, fileX));
   }
@@ -60,7 +65,8 @@
     File cacheDir = tmpDir.newFolder();
     File fileX = tmpDir.newFile("x");
 
-    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir);
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir,
+        /* maxCacheSizeBytes */ Optional.<Long>absent());
 
     Files.write("x", fileX, Charsets.UTF_8);
     InputRule inputRuleX = new InputRuleForTest(fileX);
@@ -83,7 +89,8 @@
     File cacheDir = tmpDir.newFolder();
     File fileX = tmpDir.newFile("x");
 
-    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir);
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir,
+        /* maxCacheSizeBytes */ Optional.of(0L));
 
     Files.write("x", fileX, Charsets.UTF_8);
     InputRule inputRuleX = new InputRuleForTest(fileX);
@@ -103,7 +110,8 @@
     File fileY = tmpDir.newFile("y");
     File fileZ = tmpDir.newFile("z");
 
-    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir);
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir,
+      /* maxCacheSizeBytes */ Optional.of(0L));
 
     Files.write("x", fileX, Charsets.UTF_8);
     Files.write("y", fileY, Charsets.UTF_8);
@@ -140,6 +148,82 @@
     assertEquals(inputRuleX, new InputRuleForTest(fileX));
     assertEquals(inputRuleY, new InputRuleForTest(fileY));
     assertEquals(inputRuleZ, new InputRuleForTest(fileZ));
+
+    assertEquals(3, cacheDir.listFiles().length);
+
+    dirArtifactCache.deleteOldFiles();
+
+    assertEquals(0, cacheDir.listFiles().length);
+  }
+
+  @Test
+  public void testDeleteNothing() throws IOException {
+    File cacheDir = tmpDir.newFolder();
+    File fileX = new File(cacheDir, "x");
+    File fileY = new File(cacheDir, "y");
+    File fileZ = new File(cacheDir, "z");
+
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(tmpDir.getRoot(),
+      /* maxCacheSizeBytes */ Optional.of(1024L));
+
+    Files.write("x", fileX, Charsets.UTF_8);
+    Files.write("y", fileY, Charsets.UTF_8);
+    Files.write("z", fileZ, Charsets.UTF_8);
+
+    assertEquals(3, cacheDir.listFiles().length);
+
+    dirArtifactCache.deleteOldFiles();
+
+    assertEquals(3, cacheDir.listFiles().length);
+  }
+
+  @Test
+  public void testDeleteNothingAbsentLimit() throws IOException {
+    File cacheDir = tmpDir.newFolder();
+    File fileX = new File(cacheDir, "x");
+    File fileY = new File(cacheDir, "y");
+    File fileZ = new File(cacheDir, "z");
+
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(tmpDir.getRoot(),
+      /* maxCacheSizeBytes */ Optional.<Long>absent());
+
+    Files.write("x", fileX, Charsets.UTF_8);
+    Files.write("y", fileY, Charsets.UTF_8);
+    Files.write("z", fileZ, Charsets.UTF_8);
+
+    assertEquals(3, cacheDir.listFiles().length);
+
+    dirArtifactCache.deleteOldFiles();
+
+    assertEquals(3, cacheDir.listFiles().length);
+  }
+
+  @Test
+  public void testDeleteSome() throws IOException {
+    File cacheDir = tmpDir.newFolder();
+    File fileW = new File(cacheDir, "w");
+    File fileX = new File(cacheDir, "x");
+    File fileY = new File(cacheDir, "y");
+    File fileZ = new File(cacheDir, "z");
+
+    DirArtifactCache dirArtifactCache = new DirArtifactCache(cacheDir,
+      /* maxCacheSizeBytes */ Optional.of(2L));
+
+    Files.write("w", fileW, Charsets.UTF_8);
+    Files.write("x", fileX, Charsets.UTF_8);
+    Files.write("y", fileY, Charsets.UTF_8);
+    Files.write("z", fileZ, Charsets.UTF_8);
+
+    java.nio.file.Files.setAttribute(fileW.toPath(), "lastAccessTime", FileTime.fromMillis(9000));
+    java.nio.file.Files.setAttribute(fileX.toPath(), "lastAccessTime", FileTime.fromMillis(0));
+    java.nio.file.Files.setAttribute(fileY.toPath(), "lastAccessTime", FileTime.fromMillis(1000));
+    java.nio.file.Files.setAttribute(fileZ.toPath(), "lastAccessTime", FileTime.fromMillis(2000));
+
+    assertEquals(4, cacheDir.listFiles().length);
+
+    dirArtifactCache.deleteOldFiles();
+
+    assertEquals(ImmutableSet.of(fileZ, fileW), ImmutableSet.copyOf(cacheDir.listFiles()));
   }
 
   private static class InputRuleForTest extends InputRule {
diff --git a/test/com/facebook/buck/util/unit/BUCK b/test/com/facebook/buck/util/unit/BUCK
new file mode 100644
index 0000000..dde5d3b
--- /dev/null
+++ b/test/com/facebook/buck/util/unit/BUCK
@@ -0,0 +1,12 @@
+java_test(
+  name = 'unit',
+  srcs = glob(['*.java']),
+  source_under_test = [
+    '//src/com/facebook/buck/util/unit:unit',
+  ],
+  deps = [
+    '//lib:guava',
+    '//lib:junit',
+    '//src/com/facebook/buck/util/unit:unit',
+  ],
+)