001/* 002 * (C) Copyright 2006-2011 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Florent Guillaume 018 */ 019package org.nuxeo.common.file; 020 021import static java.nio.file.StandardCopyOption.ATOMIC_MOVE; 022import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; 023 024import java.io.File; 025import java.io.FileOutputStream; 026import java.io.IOException; 027import java.io.InputStream; 028import java.nio.file.DirectoryStream; 029import java.nio.file.FileAlreadyExistsException; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.nio.file.attribute.FileTime; 033import java.util.ArrayList; 034import java.util.Collections; 035import java.util.List; 036import java.util.concurrent.locks.Lock; 037import java.util.concurrent.locks.ReentrantLock; 038import java.util.regex.Pattern; 039 040import org.apache.commons.io.IOUtils; 041import org.apache.commons.logging.Log; 042import org.apache.commons.logging.LogFactory; 043 044/** 045 * A LRU cache of {@link File}s with maximum filesystem size. 046 * <p> 047 * Cache entries that are old enough and whose size makes the cache bigger than its maximum size are deleted. 048 * <p> 049 * The cache keys are restricted to a subset of ASCII: letters, digits and dashes. Usually a MD5 or SHA1 hash is used. 050 */ 051public class LRUFileCache implements FileCache { 052 053 private static final Log log = LogFactory.getLog(LRUFileCache.class); 054 055 /** Allowed key pattern, used as file path. */ 056 public static final Pattern SIMPLE_ASCII = Pattern.compile("[-_.@;a-zA-Z0-9]+"); 057 058 private static final String TMP_PREFIX = "nxbin_"; 059 060 private static final String TMP_SUFFIX = ".tmp"; 061 062 public static final long CLEAR_OLD_ENTRIES_INTERVAL_MILLIS_DEFAULT = 5000; // 5 s 063 064 protected long clearOldEntriesIntervalMillis = CLEAR_OLD_ENTRIES_INTERVAL_MILLIS_DEFAULT; 065 066 protected static class PathInfo implements Comparable<PathInfo> { 067 068 protected final Path path; 069 070 protected final long time; 071 072 protected final long size; 073 074 public PathInfo(Path path) throws IOException { 075 this.path = path; 076 this.time = Files.getLastModifiedTime(path).toMillis(); 077 this.size = Files.size(path); 078 } 079 080 @Override 081 public int compareTo(PathInfo other) { 082 return Long.compare(other.time, time); // compare in reverse order (most recent first) 083 } 084 } 085 086 protected final Path dir; 087 088 protected final long maxSize; 089 090 protected final long maxCount; 091 092 protected final long minAgeMillis; 093 094 protected Lock clearOldEntriesLock = new ReentrantLock(); 095 096 protected long clearOldEntriesLast; 097 098 /** 099 * Constructs a cache in the given directory with the given maximum size (in bytes). 100 * 101 * @param dir the directory to use to store cached files 102 * @param maxSize the maximum size of the cache (in bytes) 103 * @param maxCount the maximum number of files in the cache 104 * @param minAge the minimum age of a file in the cache to be eligible for removal (in seconds) 105 */ 106 public LRUFileCache(File dir, long maxSize, long maxCount, long minAge) { 107 this.dir = dir.toPath(); 108 this.maxSize = maxSize; 109 this.maxCount = maxCount; 110 this.minAgeMillis = minAge * 1000; 111 } 112 113 // for tests 114 public void setClearOldEntriesIntervalMillis(long millis) { 115 clearOldEntriesIntervalMillis = millis; 116 } 117 118 /** 119 * Filter keeping regular files that aren't temporary. 120 */ 121 protected static class RegularFileFilter implements DirectoryStream.Filter<Path> { 122 123 protected static final RegularFileFilter INSTANCE = new RegularFileFilter(); 124 125 @Override 126 public boolean accept(Path path) { 127 if (!Files.isRegularFile(path)) { 128 return false; 129 } 130 String filename = path.getFileName().toString(); 131 if (filename.startsWith(TMP_PREFIX) && filename.endsWith(TMP_SUFFIX)) { 132 return false; 133 } 134 return true; 135 } 136 } 137 138 @Override 139 public long getSize() { 140 long size = 0; 141 try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) { 142 for (Path path : ds) { 143 size += Files.size(path); 144 } 145 } catch (IOException e) { 146 log.error(e, e); 147 } 148 return size; 149 } 150 151 @Override 152 public int getNumberOfItems() { 153 int count = 0; 154 try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) { 155 for (Path path : ds) { 156 count++; 157 } 158 } catch (IOException e) { 159 log.error(e, e); 160 } 161 return count; 162 } 163 164 @Override 165 public void clear() { 166 try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) { 167 for (Path path : ds) { 168 try { 169 Files.delete(path); 170 } catch (IOException e) { 171 log.error(e, e); 172 } 173 } 174 } catch (IOException e) { 175 log.error(e, e); 176 } 177 } 178 179 /** 180 * Clears cache entries if they are old enough and their size makes the cache bigger than its maximum size. 181 */ 182 protected void clearOldEntries() { 183 if (clearOldEntriesLock.tryLock()) { 184 try { 185 if (System.currentTimeMillis() > clearOldEntriesLast + clearOldEntriesIntervalMillis) { 186 doClearOldEntries(); 187 clearOldEntriesLast = System.currentTimeMillis(); 188 return; 189 } 190 } finally { 191 clearOldEntriesLock.unlock(); 192 } 193 } 194 // else don't do anything, another thread is already clearing old entries 195 } 196 197 protected void doClearOldEntries() { 198 List<PathInfo> files = new ArrayList<>(); 199 try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) { 200 for (Path path : ds) { 201 try { 202 files.add(new PathInfo(path)); 203 } catch (IOException e) { 204 log.error(e, e); 205 } 206 } 207 } catch (IOException e) { 208 log.error(e, e); 209 } 210 Collections.sort(files); // sort by most recent first 211 212 long size = 0; 213 long count = 0; 214 long threshold = System.currentTimeMillis() - minAgeMillis; 215 for (PathInfo pi : files) { 216 size += pi.size; 217 count++; 218 if (pi.time < threshold) { 219 // old enough to be candidate 220 if (size > maxSize || count > maxCount) { 221 // delete file 222 try { 223 Files.delete(pi.path); 224 size -= pi.size; 225 count--; 226 } catch (IOException e) { 227 log.error(e, e); 228 } 229 } 230 } 231 } 232 } 233 234 @Override 235 public File getTempFile() throws IOException { 236 // make sure we have a temporary directory 237 // even if it's been deleted by an external process doing cleanup 238 Files.createDirectories(dir); 239 return Files.createTempFile(dir, TMP_PREFIX, TMP_SUFFIX).toFile(); 240 } 241 242 protected void checkKey(String key) throws IllegalArgumentException { 243 if (!SIMPLE_ASCII.matcher(key).matches() || ".".equals(key) || "..".equals(key)) { 244 throw new IllegalArgumentException("Invalid key: " + key); 245 } 246 } 247 248 /** 249 * {@inheritDoc} 250 * <p> 251 * The key is used as a file name in the directory cache. 252 */ 253 @Override 254 public File putFile(String key, InputStream in) throws IOException { 255 File tmp; 256 try { 257 // check the cache 258 checkKey(key); 259 Path path = dir.resolve(key); 260 if (Files.exists(path)) { 261 recordAccess(path); 262 return path.toFile(); 263 } 264 265 // store the stream in a temporary file 266 tmp = getTempFile(); 267 try (FileOutputStream out = new FileOutputStream(tmp)) { 268 IOUtils.copy(in, out); 269 } 270 } finally { 271 in.close(); 272 } 273 return putFile(key, tmp); 274 } 275 276 /** 277 * {@inheritDoc} 278 * <p> 279 * The key is used as a file name in the directory cache. 280 */ 281 @Override 282 public File putFile(String key, File file) throws IllegalArgumentException, IOException { 283 Path source = file.toPath(); 284 285 // put file in cache 286 checkKey(key); 287 Path path = dir.resolve(key); 288 try { 289 Files.move(source, path, ATOMIC_MOVE, REPLACE_EXISTING); 290 recordAccess(path); 291 clearOldEntries(); 292 } catch (FileAlreadyExistsException faee) { 293 // already something there 294 recordAccess(path); 295 // remove unused tmp file 296 try { 297 Files.delete(source); 298 } catch (IOException e) { 299 log.error(e, e); 300 } 301 } 302 return path.toFile(); 303 } 304 305 @Override 306 public File getFile(String key) { 307 checkKey(key); 308 Path path = dir.resolve(key); 309 if (!Files.exists(path)) { 310 return null; 311 } 312 recordAccess(path); 313 return path.toFile(); 314 } 315 316 /** Records access to a file by changing its modification time. */ 317 protected void recordAccess(Path path) { 318 try { 319 Files.setLastModifiedTime(path, FileTime.fromMillis(System.currentTimeMillis())); 320 } catch (IOException e) { 321 log.error(e, e); 322 } 323 } 324 325}