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