001/* ***** BEGIN LICENSE BLOCK *****
002 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
003 *
004 * The contents of this file are subject to the Mozilla Public License Version
005 * 1.1 (the "License"); you may not use this file except in compliance with
006 * the License. You may obtain a copy of the License at
007 * http://www.mozilla.org/MPL/
008 *
009 * Software distributed under the License is distributed on an "AS IS" basis,
010 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
011 * for the specific language governing rights and limitations under the
012 * License.
013 *
014 * The Original Code is part of dcm4che, an implementation of DICOM(TM) in
015 * Java(TM), hosted at https://github.com/gunterze/dcm4che.
016 *
017 * The Initial Developer of the Original Code is
018 * Agfa Healthcare.
019 * Portions created by the Initial Developer are Copyright (C) 2011-2014
020 * the Initial Developer. All Rights Reserved.
021 *
022 * Contributor(s):
023 * See @authors listed below
024 *
025 * Alternatively, the contents of this file may be used under the terms of
026 * either the GNU General Public License Version 2 or later (the "GPL"), or
027 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
028 * in which case the provisions of the GPL or the LGPL are applicable instead
029 * of those above. If you wish to allow use of your version of this file only
030 * under the terms of either the GPL or the LGPL, and not to allow others to
031 * use your version of this file under the terms of the MPL, indicate your
032 * decision by deleting the provisions above and replace them with the notice
033 * and other provisions required by the GPL or the LGPL. If you do not delete
034 * the provisions above, a recipient may use your version of this file under
035 * the terms of any one of the MPL, the GPL or the LGPL.
036 *
037 * ***** END LICENSE BLOCK ***** */
038
039package org.dcm4che3.filecache;
040
041import java.io.BufferedReader;
042import java.io.IOException;
043import java.nio.charset.Charset;
044import java.nio.file.DirectoryNotEmptyException;
045import java.nio.file.DirectoryStream;
046import java.nio.file.Files;
047import java.nio.file.Path;
048import java.nio.file.StandardOpenOption;
049import java.nio.file.attribute.FileTime;
050import java.text.SimpleDateFormat;
051import java.util.ArrayList;
052import java.util.Collection;
053import java.util.Collections;
054import java.util.Date;
055import java.util.TreeSet;
056import java.util.concurrent.atomic.AtomicBoolean;
057
058import org.slf4j.Logger;
059import org.slf4j.LoggerFactory;
060
061/**
062 * @author Gunter Zeilinger <gunterze@gmail.com>
063 *
064 */
065public class FileCache {
066
067    private static final Logger LOG = LoggerFactory.getLogger(FileCache.class);
068    private static final Charset UTF_8 = Charset.forName("UTF-8");
069
070    private Path fileCacheRootDirectory;
071    private Path journalRootDirectory;
072    private String journalFileName = "journal";
073    private String orphanedFileName = "orphaned";
074    private String journalDirectoryName = "journal.d";
075    private SimpleDateFormat journalFileNamePattern =
076            new SimpleDateFormat("yyyyMMdd/HHmmss.SSS");
077    private int journalMaxEntries = 100;
078    private boolean leastRecentlyUsed;
079    private int currentJournalNumEntries = -1;
080    private final AtomicBoolean freeIsRunning = new AtomicBoolean();
081
082    public Path getFileCacheRootDirectory() {
083        return fileCacheRootDirectory;
084    }
085
086    public void setFileCacheRootDirectory(Path fileCacheRootDirectory) {
087        this.fileCacheRootDirectory = fileCacheRootDirectory;
088    }
089
090    public Path getJournalRootDirectory() {
091        return journalRootDirectory;
092    }
093
094    public void setJournalRootDirectory(Path journalRootDirectory) {
095        this.journalRootDirectory = journalRootDirectory;
096    }
097
098    public String getJournalFileName() {
099        return journalFileName;
100    }
101
102    public void setJournalFileName(String journalFileName) {
103        this.journalFileName = journalFileName;
104    }
105
106    public Path getJournalFile() {
107        return journalRootDirectory.resolve(journalFileName);
108    }
109
110    public String getJournalDirectoryName() {
111        return journalDirectoryName;
112    }
113
114    public void setJournalDirectoryName(String journalDirectoryName) {
115        this.journalDirectoryName = journalDirectoryName;
116    }
117
118    public Path getJournalDirectory() {
119        return journalRootDirectory.resolve(journalDirectoryName);
120    }
121
122    public String getJournalFileNamePattern() {
123        return journalFileNamePattern.toPattern();
124    }
125
126    public void setJournalFileNamePattern(String pattern) {
127        this.journalFileNamePattern = new SimpleDateFormat(pattern);
128    }
129
130    public int getJournalMaxEntries() {
131        return journalMaxEntries;
132    }
133
134    public void setJournalMaxEntries(int journalMaxEntries) {
135        this.journalMaxEntries = journalMaxEntries;
136    }
137
138    public String getOrphanedFileName() {
139        return orphanedFileName;
140    }
141
142    public void setOrphanedFileName(String orphanedFileName) {
143        this.orphanedFileName = orphanedFileName;
144    }
145
146    public Path getOrphanedFile() {
147        return journalRootDirectory.resolve(orphanedFileName);
148    }
149
150    public Collection<Path> getOrphanedFiles() throws IOException {
151        Path orphanFile = getOrphanedFile();
152        if (Files.notExists(orphanFile))
153            return Collections.emptyList();
154        
155        ArrayList<Path> files = new ArrayList<Path>();
156        try (BufferedReader r = Files.newBufferedReader(
157                orphanFile, UTF_8)) {
158            String fileName;
159            while ((fileName = r.readLine()) != null) {
160                files.add(fileCacheRootDirectory.resolve(fileName));
161            }
162        }
163        return files;
164    }
165
166    public boolean isLeastRecentlyUsed() {
167        return leastRecentlyUsed;
168    }
169
170    public void setLeastRecentlyUsed(boolean leastRecentlyUsed) {
171        this.leastRecentlyUsed = leastRecentlyUsed;
172    }
173
174    @Override 
175    public String toString() {
176        return "FileCache[cacheDir=" + fileCacheRootDirectory
177                + ", journalDir=" + journalRootDirectory + "]";
178    }
179
180    public synchronized void register(Path path) throws IOException {
181        LOG.debug("{}: registering - {}", this, path);
182        Files.createDirectories(journalRootDirectory);
183        Path journalFile = getJournalFile();
184        String entry = fileCacheRootDirectory.relativize(path).toString();
185        int numEntries = currentJournalNumEntries;
186        if (numEntries < 0)
187            numEntries = countLines(journalFile);
188        if (numEntries >= journalMaxEntries) {
189            moveJournalFile(journalFile);
190            numEntries = 0;
191        }
192        Files.write(journalFile, Collections.singleton(entry), UTF_8,
193                StandardOpenOption.CREATE, StandardOpenOption.APPEND);
194        currentJournalNumEntries = numEntries + 1;
195        if (leastRecentlyUsed) {
196            try {
197                LOG.debug("{}: update modification time of - {}", this, path);
198                Files.setLastModifiedTime(path, Files.getLastModifiedTime(journalFile));
199            } catch (IOException e) {
200                LOG.info("{}: failed to update modification time of - {}", this, path, e);
201            }
202        }
203        LOG.debug("{}: registered - {}", this, path);
204    }
205
206    private void moveJournalFile(Path journalFile) throws IOException {
207        Path target = getJournalDirectory().resolve(
208                journalFileNamePattern.format(new Date()));
209        LOG.debug("{}: maximal number of journal entries [{}] exeeded, move {} to {}", 
210                this, journalMaxEntries, journalFile, target);
211        Files.createDirectories(target.getParent());
212        Files.move(journalFile, target);
213    }
214
215    public boolean access(Path path) throws IOException {
216        if (!Files.exists(path))
217            return false;
218
219        if (leastRecentlyUsed)
220            register(path);
221
222        return true;
223    }
224
225    public long free(long size) throws IOException {
226        LOG.info("{}: try to free {} bytes", this, size);
227        if (!freeIsRunning.compareAndSet(false, true)) {
228            LOG.info("{}: free already running", this);
229            return -1;
230        }
231
232        try {
233            long freed = free(getJournalDirectory(), size);
234            LOG.info("{}: freed {} bytes", this, freed);
235            return freed;
236        } finally {
237            freeIsRunning.set(false);
238        }
239    }
240
241    private long free(Path dir, long size) throws IOException {
242        long remaining = size;
243        for (Path file : listFiles(dir)) {
244            if (Files.isDirectory(file)) {
245                remaining -= free(file, remaining);
246            } else {
247                remaining -= free(file);
248            }
249            if (remaining <= 0)
250                break;
251        }
252        return size - remaining;
253    }
254
255    private Collection<Path> listFiles(Path dir) throws IOException {
256        TreeSet<Path> files = new TreeSet<Path>();
257        try (DirectoryStream<Path> dirPath = Files.newDirectoryStream(dir)) {
258            for (Path path : dirPath)
259                files.add(path);
260        }
261        return files;
262    }
263
264    public void clear() throws IOException {
265        LOG.info("{}: clearing", this);
266        deleteDirContent(fileCacheRootDirectory);
267        deleteDirContent(journalRootDirectory);
268        LOG.info("{}: cleared", this);
269    }
270
271    private int countLines(Path journalFile) throws IOException {
272        int lines = 0;
273        if (Files.exists(journalFile))
274            try (BufferedReader r = Files.newBufferedReader(journalFile, UTF_8)) {
275                while (r.readLine() != null)
276                    lines ++;
277            }
278        return lines;
279    }
280
281    private static void deleteDirContent(Path dir) throws IOException {
282        if (Files.exists(dir)) {
283            try ( DirectoryStream<Path> in = Files.newDirectoryStream(dir) ) {
284                for (Path path : in) {
285                    if (Files.isDirectory(path))
286                        deleteDirContent(path);
287                    Files.delete(path);
288                }
289            }
290        }
291    }
292
293    private long free(Path journalFile) throws IOException {
294        LOG.debug("{}: deleting files referenced by journal - {}",
295                this, journalFile);
296        long freed = 0L;
297        FileTime lastModifiedTime = Files.getLastModifiedTime(journalFile);
298        try (BufferedReader r = Files.newBufferedReader(
299                journalFile, UTF_8)) {
300            String fileName;
301            while ((fileName = r.readLine()) != null) {
302                Path path = fileCacheRootDirectory.resolve(fileName);
303                if (Files.notExists(path)) {
304                    LOG.debug("{}: {} already deleted");
305                    continue;
306                }
307                if (leastRecentlyUsed) {
308                    try {
309                        if (Files.getLastModifiedTime(path)
310                                .compareTo(lastModifiedTime) > 0)  {
311                            LOG.debug("{}: {} recently accessed - do not delete",
312                                    this, path);
313                            continue;
314                        }
315                    } catch (IOException e) {
316                        LOG.info("{}: failed to get modification time of - {}",
317                                this, path, e);
318                    }
319                }
320                try {
321                    LOG.debug("{}: delete - {}", this, path);
322                    long fileSize = Files.size(path);
323                    Files.delete(path);
324                    freed += fileSize;
325                    purgeEmptyDirectories(path.getParent(), fileCacheRootDirectory);
326                } catch (IOException e) {
327                    LOG.warn("{}: failed to delete - {}", this, path, e);
328                    try {
329                        Path orphanedFile = getOrphanedFile();
330                        Files.write(orphanedFile, Collections.singleton(fileName), UTF_8,
331                                StandardOpenOption.CREATE, StandardOpenOption.APPEND);
332                    } catch (IOException e2) {
333                        LOG.warn("{}: failed to record orphaned file - {}",
334                                this, path, e2);
335                    }
336                }
337            }
338        }
339        try {
340            LOG.debug("{}: delete journal - {}", this, journalFile);
341            Files.delete(journalFile);
342            purgeEmptyDirectories(journalFile.getParent(), getJournalDirectory());
343        } catch (IOException e) {
344            LOG.warn("{}: failed to delete journal - {}", this, journalFile, e);
345        }
346        LOG.debug("{}: deleted files referenced by journal - {} - freed {} bytes",
347                this, journalFile, freed);
348        return freed;
349    }
350
351    private void purgeEmptyDirectories(Path dir, Path root) {
352        while (!dir.equals(root))
353            try {
354                Files.delete(dir);
355                LOG.debug("{}: purged empty directory - {}", this, dir);
356                dir = dir.getParent();
357            } catch (DirectoryNotEmptyException e) {
358                return;
359            } catch (IOException e) {
360                LOG.warn("{}: failed to purge empty directory {}", this, dir, e);
361                return;
362            }
363    }
364
365}