/*
* (C) Copyright 2006-2011 Nuxeo SA (http://nuxeo.com/) and others.
*
* 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.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.common.file;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* A LRU cache of {@link File}s with maximum filesystem size.
* <p>
* Cache entries that are old enough and whose size makes the cache bigger than its maximum size are deleted.
* <p>
* The cache keys are restricted to a subset of ASCII: letters, digits and dashes. Usually a MD5 or SHA1 hash is used.
*/
public class LRUFileCache implements FileCache {
private static final Log log = LogFactory.getLog(LRUFileCache.class);
/** Allowed key pattern, used as file path. */
public static final Pattern SIMPLE_ASCII = Pattern.compile("[-_a-zA-Z0-9]+");
private static final String TMP_PREFIX = "nxbin_";
private static final String TMP_SUFFIX = ".tmp";
// not final for tests
public static long CLEAR_OLD_ENTRIES_INTERVAL_MILLIS = 5000; // 5 s
protected static class PathInfo implements Comparable<PathInfo> {
protected final Path path;
protected final long time;
protected final long size;
public PathInfo(Path path) throws IOException {
this.path = path;
this.time = Files.getLastModifiedTime(path).toMillis();
this.size = Files.size(path);
}
@Override
public int compareTo(PathInfo other) {
return Long.compare(other.time, time); // compare in reverse order (most recent first)
}
}
protected final Path dir;
protected final long maxSize;
protected final long maxCount;
protected final long minAgeMillis;
protected Lock clearOldEntriesLock = new ReentrantLock();
protected long clearOldEntriesLast;
/**
* Constructs a cache in the given directory with the given maximum size (in bytes).
*
* @param dir the directory to use to store cached files
* @param maxSize the maximum size of the cache (in bytes)
* @param maxCount the maximum number of files in the cache
* @param minAge the minimum age of a file in the cache to be eligible for removal (in seconds)
*/
public LRUFileCache(File dir, long maxSize, long maxCount, long minAge) {
this.dir = dir.toPath();
this.maxSize = maxSize;
this.maxCount = maxCount;
this.minAgeMillis = minAge * 1000;
}
/**
* Filter keeping regular files that aren't temporary.
*/
protected static class RegularFileFilter implements DirectoryStream.Filter<Path> {
protected static final RegularFileFilter INSTANCE = new RegularFileFilter();
@Override
public boolean accept(Path path) {
if (!Files.isRegularFile(path)) {
return false;
}
String filename = path.getFileName().toString();
if (filename.startsWith(TMP_PREFIX) && filename.endsWith(TMP_SUFFIX)) {
return false;
}
return true;
}
}
@Override
public long getSize() {
long size = 0;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) {
for (Path path : ds) {
size += Files.size(path);
}
} catch (IOException e) {
log.error(e, e);
}
return size;
}
@Override
public int getNumberOfItems() {
int count = 0;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) {
for (Path path : ds) {
count++;
}
} catch (IOException e) {
log.error(e, e);
}
return count;
}
@Override
public void clear() {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) {
for (Path path : ds) {
try {
Files.delete(path);
} catch (IOException e) {
log.error(e, e);
}
}
} catch (IOException e) {
log.error(e, e);
}
}
/**
* Clears cache entries if they are old enough and their size makes the cache bigger than its maximum size.
*/
protected void clearOldEntries() {
if (clearOldEntriesLock.tryLock()) {
try {
if (System.currentTimeMillis() > clearOldEntriesLast + CLEAR_OLD_ENTRIES_INTERVAL_MILLIS) {
doClearOldEntries();
clearOldEntriesLast = System.currentTimeMillis();
return;
}
} finally {
clearOldEntriesLock.unlock();
}
}
// else don't do anything, another thread is already clearing old entries
}
protected void doClearOldEntries() {
List<PathInfo> files = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, RegularFileFilter.INSTANCE)) {
for (Path path : ds) {
try {
files.add(new PathInfo(path));
} catch (IOException e) {
log.error(e, e);
}
}
} catch (IOException e) {
log.error(e, e);
}
Collections.sort(files); // sort by most recent first
long size = 0;
long count = 0;
long threshold = System.currentTimeMillis() - minAgeMillis;
for (PathInfo pi : files) {
size += pi.size;
count++;
if (pi.time < threshold) {
// old enough to be candidate
if (size > maxSize || count > maxCount) {
// delete file
try {
Files.delete(pi.path);
size -= pi.size;
count--;
} catch (IOException e) {
log.error(e, e);
}
}
}
}
}
@Override
public File getTempFile() throws IOException {
return Files.createTempFile(dir, TMP_PREFIX, TMP_SUFFIX).toFile();
}
protected void checkKey(String key) throws IllegalArgumentException {
if (!SIMPLE_ASCII.matcher(key).matches() || ".".equals(key) || "..".equals(key)) {
throw new IllegalArgumentException("Invalid key: " + key);
}
}
/**
* {@inheritDoc}
* <p>
* The key is used as a file name in the directory cache.
*/
@Override
public File putFile(String key, InputStream in) throws IOException {
File tmp;
try {
// check the cache
checkKey(key);
Path path = dir.resolve(key);
if (Files.exists(path)) {
recordAccess(path);
return path.toFile();
}
// store the stream in a temporary file
tmp = getTempFile();
try (FileOutputStream out = new FileOutputStream(tmp)) {
IOUtils.copy(in, out);
}
} finally {
in.close();
}
return putFile(key, tmp);
}
/**
* {@inheritDoc}
* <p>
* The key is used as a file name in the directory cache.
*/
@Override
public File putFile(String key, File file) throws IllegalArgumentException, IOException {
Path source = file.toPath();
// put file in cache
checkKey(key);
Path path = dir.resolve(key);
try {
Files.move(source, path);
recordAccess(path);
clearOldEntries();
} catch (FileAlreadyExistsException faee) {
// already something there
recordAccess(path);
// remove unused tmp file
try {
Files.delete(source);
} catch (IOException e) {
log.error(e, e);
}
}
return path.toFile();
}
@Override
public File getFile(String key) {
checkKey(key);
Path path = dir.resolve(key);
if (!Files.exists(path)) {
return null;
}
recordAccess(path);
return path.toFile();
}
/** Records access to a file by changing its modification time. */
protected void recordAccess(Path path) {
try {
Files.setLastModifiedTime(path, FileTime.fromMillis(System.currentTimeMillis()));
} catch (IOException e) {
log.error(e, e);
}
}
}