1 | // Jomic - a viewer for comic book archives. |
2 | // Copyright (C) 2004-2011 Thomas Aglassinger |
3 | // |
4 | // This program is free software: you can redistribute it and/or modify |
5 | // it under the terms of the GNU General Public License as published by |
6 | // the Free Software Foundation, either version 3 of the License, or |
7 | // (at your option) any later version. |
8 | // |
9 | // This program is distributed in the hope that it will be useful, |
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | // GNU General Public License for more details. |
13 | // |
14 | // You should have received a copy of the GNU General Public License |
15 | // along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | package net.sf.jomic.tools; |
17 | |
18 | import java.io.File; |
19 | import java.io.FileNotFoundException; |
20 | import java.io.FileReader; |
21 | import java.io.FileWriter; |
22 | import java.io.IOException; |
23 | import java.io.Reader; |
24 | import java.io.Writer; |
25 | import java.util.Arrays; |
26 | import java.util.HashMap; |
27 | import java.util.Iterator; |
28 | import java.util.Map; |
29 | |
30 | import org.apache.commons.logging.Log; |
31 | import org.apache.commons.logging.LogFactory; |
32 | import org.w3c.dom.Document; |
33 | import org.w3c.dom.Element; |
34 | import org.xml.sax.Attributes; |
35 | import org.xml.sax.ContentHandler; |
36 | import org.xml.sax.InputSource; |
37 | import org.xml.sax.Locator; |
38 | import org.xml.sax.SAXException; |
39 | import org.xml.sax.XMLReader; |
40 | import org.xml.sax.helpers.XMLReaderFactory; |
41 | |
42 | /** |
43 | * Cache for extracted archives. The cache maps the file path a a comic book archive to a directory |
44 | * where the extracted images are restored. While there is a size limit, it can temporarily be |
45 | * exeeded, for example because a single comic is bigger than the limit, or no comics can be |
46 | * removed because all of them are locked by a client right now. |
47 | * |
48 | * @see net.sf.jomic.tools.ArchiveCacheEntry |
49 | * @author Thomas Aglassinger |
50 | */ |
51 | public class ArchiveCache implements CacheInfo |
52 | { |
53 | private static final String ATTRIBUTE_INDEX = "index"; |
54 | private static final String ATTRIBUTE_LAST_ACCESSED = "lastAccessed"; |
55 | private static final String ATTRIBUTE_LAST_MODIFIED = "lastModified"; |
56 | private static final String ATTRIBUTE_SOURCE = "source"; |
57 | private static final String ATTRIBUTE_VERSION = "version"; |
58 | private static final String ELEMENT_ARCHIVE = "archive"; |
59 | private static final String ELEMENT_ARCHIVE_CACHE = "archiveCache"; |
60 | private static final String EXPECTED_VERSION = "1.0"; |
61 | |
62 | //@ public model JMLDataGroup sizes; |
63 | private File baseDir; |
64 | private /*@ spec_public @*/ Map entries; //@ in sizes; |
65 | private FileTools fileTools; |
66 | private LocaleTools localeTools; |
67 | private Log logger; |
68 | private long maxSize; //@ in sizes; |
69 | private StringTools stringTools; |
70 | private /*@ spec_public @*/ long usedSize; //@ in sizes; |
71 | private XmlTools xmlTools; |
72 | |
73 | /** |
74 | * Create ArchiveCache storing extracted files and index in the directory <code>newBaseDir</code> |
75 | * . This directory must not contain any other data because clearing the cache means deleting |
76 | * this directory. |
77 | * |
78 | * @throws FileNotFoundException if <code>newBaseDir</code> cannot be created |
79 | * @param newBaseDir the directory where extracted files will be stored |
80 | * @param newMaxSize intended maximum size in bytes extracted archives should use |
81 | * before older files are removed from the cache |
82 | */ |
83 | //@ requires newMaxSize >= 0; |
84 | public ArchiveCache(File newBaseDir, long newMaxSize) |
85 | throws FileNotFoundException { |
86 | logger = LogFactory.getLog(ArchiveCache.class); |
87 | fileTools = FileTools.instance(); |
88 | localeTools = LocaleTools.instance(); |
89 | stringTools = StringTools.instance(); |
90 | xmlTools = XmlTools.instance(); |
91 | |
92 | baseDir = newBaseDir; |
93 | entries = new HashMap(); |
94 | fileTools.mkdirs(baseDir); |
95 | maxSize = newMaxSize; |
96 | } |
97 | |
98 | /** |
99 | * Set the number of bytes used by <code>entry</code> by computing the number of bytes used by |
100 | * all files stored in its archive cache directory. |
101 | */ |
102 | //@ requires entries.containsValue(entry); |
103 | //@ assignable sizes; |
104 | public void setCacheEntrySize(ArchiveCacheEntry entry) { |
105 | setCacheEntrySizeWithoutAdjustSize(entry); |
106 | adjustSize(); |
107 | } |
108 | |
109 | /** |
110 | * Set the number of bytes used by <code>entry</code> by computing the number of bytes used by |
111 | * all files stored in its archive cache directory. Use this only when iterating over the |
112 | * whole cache as it does not adjust the total cache size. This prevents |
113 | * ConcurrentModificationExceptions from happening. |
114 | */ |
115 | private void setCacheEntrySizeWithoutAdjustSize(ArchiveCacheEntry entry) { |
116 | usedSize -= entry.getSize(); |
117 | entry.setSize(getBaseDir()); |
118 | usedSize += entry.getSize(); |
119 | } |
120 | |
121 | /** |
122 | * Set the maximum number of bytes the cache should use. If the cache currently is bigger than |
123 | * that, attempt to shrink it to fit. |
124 | */ |
125 | //@ requires newMaxSize >= 0; |
126 | //@ assignable sizes; |
127 | public void setMaxSize(long newMaxSize) { |
128 | maxSize = newMaxSize; |
129 | adjustSize(); |
130 | } |
131 | |
132 | public /*@ nullable @*/ ArchiveCacheEntry get(File archiveFile) { |
133 | ArchiveCacheEntry result = null; |
134 | |
135 | synchronized (entries) { |
136 | result = (ArchiveCacheEntry) entries.get(archiveFile); |
137 | |
138 | if (result != null) { |
139 | result.updateLastAccessed(); |
140 | } |
141 | } |
142 | return result; |
143 | } |
144 | |
145 | public /*@ pure @*/ File getBaseDir() { |
146 | return baseDir; |
147 | } |
148 | |
149 | /** |
150 | * Get directory where cached files extracted from the archive are stored. |
151 | */ |
152 | public /*@ pure @*/ File getCacheEntryDir(ArchiveCacheEntry cacheEntry) { |
153 | File result = cacheEntry.getCachedDir(getBaseDir()); |
154 | |
155 | return result; |
156 | } |
157 | |
158 | public /*@ pure @*/ int getEntryCount() { |
159 | return entries.size(); |
160 | } |
161 | |
162 | public /*@ pure @*/ long getMaxSize() { |
163 | return maxSize; |
164 | } |
165 | |
166 | public /*@ pure @*/ long getUsedSize() { |
167 | return usedSize; |
168 | } |
169 | |
170 | /** |
171 | * Get the file in which the mapping of archive names to directories in the cache is stored. |
172 | */ |
173 | private /*@ pure @*/ File getCacheMapFile() { |
174 | return new File(getBaseDir(), "cacheMap.xml"); |
175 | } |
176 | |
177 | /** |
178 | * Attempt to read entries from cacheMap.xml. If the map cannot be found or is broken, start |
179 | * with an empty cache. |
180 | */ |
181 | public void attemptToReadEntries() { |
182 | boolean doClearCache = false; |
183 | File cacheMapFile = getCacheMapFile(); |
184 | |
185 | try { |
186 | FileReader reader = new FileReader(cacheMapFile); |
187 | |
188 | try { |
189 | readEntries(reader); |
190 | } finally { |
191 | reader.close(); |
192 | } |
193 | } catch (FileNotFoundException error) { |
194 | logger.info("no cache map found; using new empty map", error); |
195 | } catch (IOException error) { |
196 | logger.warn("cannot read cache map entries; clearing cache", error); |
197 | doClearCache = true; |
198 | } catch (SAXException error) { |
199 | logger.warn("cannot parse cache map; clearing cache", error); |
200 | doClearCache = true; |
201 | } |
202 | if (doClearCache) { |
203 | clear(); |
204 | } |
205 | } |
206 | |
207 | /** |
208 | * Clear entries and remove <b>all</b> files in the base directory. |
209 | */ |
210 | //@ assignable sizes; |
211 | //@ ensures getUsedSize() == 0; |
212 | //@ ensures getEntryCount() == 0; |
213 | public void clear() { |
214 | synchronized (entries) { |
215 | File[] cacheFiles = getBaseDir().listFiles(); |
216 | |
217 | for (int i = 0; i < cacheFiles.length; i += 1) { |
218 | fileTools.attemptToDeleteAll(cacheFiles[i], logger); |
219 | } |
220 | entries.clear(); |
221 | usedSize = 0; |
222 | } |
223 | } |
224 | |
225 | /** |
226 | * Create a new cache entry for <code>newArchiveFile</code> using the next free index. |
227 | */ |
228 | public ArchiveCacheEntry createCacheEntry(File newArchiveFile) { |
229 | ArchiveCacheEntry result; |
230 | int newIndex = -1; |
231 | |
232 | if (getEntryCount() > 0) { |
233 | int[] indexes = new int[getEntryCount()]; |
234 | int i = 0; |
235 | Iterator rider = entries.values().iterator(); |
236 | |
237 | while (rider.hasNext()) { |
238 | ArchiveCacheEntry entry = (ArchiveCacheEntry) rider.next(); |
239 | |
240 | indexes[i] = entry.getIndex(); |
241 | i += 1; |
242 | } |
243 | Arrays.sort(indexes); |
244 | |
245 | int firstIndex = indexes[0]; |
246 | |
247 | if (firstIndex > 0) { |
248 | newIndex = firstIndex - 1; |
249 | } else { |
250 | int currentIndex = firstIndex; |
251 | boolean freeIndexFound = false; |
252 | |
253 | i = 1; |
254 | while ((i < indexes.length) && !freeIndexFound) { |
255 | int nextIndex = indexes[i]; |
256 | |
257 | //@ assert currentIndex != nextIndex; |
258 | if (nextIndex - currentIndex > 1) { |
259 | freeIndexFound = true; |
260 | } else { |
261 | currentIndex = nextIndex; |
262 | i += 1; |
263 | } |
264 | } |
265 | newIndex = currentIndex + 1; |
266 | } |
267 | // TODO: @ assert Arrays.binarySearch(indexes, newIndex) < 0; |
268 | } else { |
269 | newIndex = 0; |
270 | } |
271 | |
272 | //@ assert newIndex >= 0; |
273 | result = new ArchiveCacheEntry(newArchiveFile, newIndex); |
274 | |
275 | return result; |
276 | } |
277 | |
278 | //@ requires entry.getSize() == 0; |
279 | //@ assignable sizes; |
280 | public void put(ArchiveCacheEntry entry) { |
281 | synchronized (entries) { |
282 | putWithoutAccess(entry); |
283 | entry.updateLastAccessed(); |
284 | entry.updateLastModified(); |
285 | setCacheEntrySize(entry); |
286 | } |
287 | } |
288 | |
289 | //@ assignable sizes; |
290 | public void readEntries(Reader reader) |
291 | throws IOException, SAXException { |
292 | String parserName = org.apache.xerces.parsers.SAXParser.class.getName(); |
293 | XMLReader xmlReader = XMLReaderFactory.createXMLReader(parserName); |
294 | ContentHandler contentHandler = new ArchiveCacheContentHandler(); |
295 | InputSource inputSource = new InputSource(reader); |
296 | |
297 | xmlReader.setContentHandler(contentHandler); |
298 | xmlReader.parse(inputSource); |
299 | |
300 | Iterator rider = entries.values().iterator(); |
301 | |
302 | // Compute size used by cache. |
303 | usedSize = 0; |
304 | while (rider.hasNext()) { |
305 | ArchiveCacheEntry cacheEntryToSetSizeFor = (ArchiveCacheEntry) rider.next(); |
306 | |
307 | setCacheEntrySizeWithoutAdjustSize(cacheEntryToSetSizeFor); |
308 | |
309 | long cacheEntrySize = cacheEntryToSetSizeFor.getSize(); |
310 | |
311 | if (logger.isDebugEnabled()) { |
312 | logger.debug("size of " + cacheEntryToSetSizeFor.getCachedDirName() |
313 | + ": " + localeTools.asByteText(cacheEntrySize)); |
314 | } |
315 | } |
316 | // Adjust size in case the settings specified a smaller size now than back then when it was |
317 | // written. |
318 | adjustSize(); |
319 | if (logger.isInfoEnabled()) { |
320 | logger.info("size of cache in " + getBaseDir() + ": " + localeTools.asByteText(usedSize)); |
321 | } |
322 | } |
323 | |
324 | //@ assignable sizes; |
325 | public void remove(File archiveFile) { |
326 | synchronized (entries) { |
327 | ArchiveCacheEntry entry = (ArchiveCacheEntry) entries.remove(archiveFile); |
328 | long oldEntrySize = entry.getSize(); |
329 | |
330 | //@ assert entry != null; |
331 | fileTools.attemptToDeleteAll(getCacheEntryDir(entry), logger); |
332 | usedSize -= oldEntrySize; |
333 | } |
334 | } |
335 | |
336 | public void writeEntries() |
337 | throws IOException { |
338 | File cacheMapFile = getCacheMapFile(); |
339 | Writer writer = new FileWriter(cacheMapFile); |
340 | |
341 | try { |
342 | writeEntries(writer); |
343 | } finally { |
344 | writer.close(); |
345 | } |
346 | } |
347 | |
348 | public void writeEntries(Writer writer) |
349 | throws IOException { |
350 | // Build the document |
351 | Document document = xmlTools.createEmptyDocument(null); |
352 | Element rootElement = document.createElement(ELEMENT_ARCHIVE_CACHE); |
353 | |
354 | synchronized (entries) { |
355 | Iterator rider = entries.values().iterator(); |
356 | |
357 | document.appendChild(rootElement); |
358 | rootElement.setAttribute(ATTRIBUTE_VERSION, EXPECTED_VERSION); |
359 | while (rider.hasNext()) { |
360 | ArchiveCacheEntry entry = (ArchiveCacheEntry) rider.next(); |
361 | Element archiveElement = document.createElement(ELEMENT_ARCHIVE); |
362 | int lockCount = entry.getLockCount(); |
363 | File archiveFile = entry.getArchiveFile(); |
364 | |
365 | if (lockCount > 0) { |
366 | logger.warn("writting entry with lockCount=" + lockCount + " (should be 0): " |
367 | + stringTools.sourced(archiveFile)); |
368 | } |
369 | archiveElement.setAttribute(ATTRIBUTE_INDEX, Integer.toString(entry.getIndex())); |
370 | archiveElement.setAttribute(ATTRIBUTE_LAST_ACCESSED, Long.toString(entry.getLastAccessed())); |
371 | archiveElement.setAttribute(ATTRIBUTE_LAST_MODIFIED, Long.toString(entry.getLastModified())); |
372 | archiveElement.setAttribute(ATTRIBUTE_SOURCE, archiveFile.getAbsolutePath()); |
373 | rootElement.appendChild(archiveElement); |
374 | } |
375 | } |
376 | xmlTools.write(document, writer); |
377 | } |
378 | |
379 | //@ assignable sizes; |
380 | private void adjustSize() { |
381 | if (usedSize > maxSize) { |
382 | // Remove old entries from the cache, but keep all locked ones. |
383 | while ((getEntryCount() >= 2) && (usedSize > maxSize)) { |
384 | ArchiveCacheEntry leastRecentlyAccessedEntry = findLeastRecentlyAccessedUnlockedEntry(); |
385 | |
386 | if (leastRecentlyAccessedEntry != null) { |
387 | remove(leastRecentlyAccessedEntry.getArchiveFile()); |
388 | } else { |
389 | logger.warn("cannot remove any cache entry dispite size exceeding maximum"); |
390 | } |
391 | } |
392 | } else { |
393 | logger.debug("no size adjustment needed"); |
394 | } |
395 | } |
396 | |
397 | private /*@ nullable pure @*/ ArchiveCacheEntry findLeastRecentlyAccessedUnlockedEntry() { |
398 | ArchiveCacheEntry result = null; |
399 | |
400 | synchronized (entries) { |
401 | Iterator rider = entries.values().iterator(); |
402 | |
403 | while (rider.hasNext()) { |
404 | ArchiveCacheEntry nextEntry = (ArchiveCacheEntry) rider.next(); |
405 | boolean unlocked = (nextEntry.getLockCount() == 0); |
406 | |
407 | if (unlocked) { |
408 | if (result != null) { |
409 | long nextAccessed = nextEntry.getLastAccessed(); |
410 | long leastRecentlyAccessed; |
411 | |
412 | leastRecentlyAccessed = result.getLastAccessed(); |
413 | |
414 | if (leastRecentlyAccessed > nextAccessed) { |
415 | result = nextEntry; |
416 | } |
417 | } else { |
418 | result = nextEntry; |
419 | } |
420 | } |
421 | } |
422 | assert (result == null) || (result.getLockCount() == 0); |
423 | } |
424 | |
425 | return result; |
426 | } |
427 | |
428 | //@ modifies entries; |
429 | private void putWithoutAccess(ArchiveCacheEntry entry) { |
430 | synchronized (entries) { |
431 | entries.put(entry.getArchiveFile(), entry); |
432 | } |
433 | } |
434 | |
435 | /** |
436 | * SAX ContentHandler to read cache index for mapping archive paths to cache diretories. |
437 | * |
438 | * @author Thomas Aglassinger |
439 | */ |
440 | private final class ArchiveCacheContentHandler implements ContentHandler |
441 | { |
442 | private ArchiveCacheContentHandler() { |
443 | super(); |
444 | } |
445 | |
446 | public void setDocumentLocator(Locator newLocator) { |
447 | // Do nothing. |
448 | } |
449 | |
450 | private String getRequiredAttribute(Attributes attributes, String attributeName) |
451 | throws SAXException { |
452 | assert attributes != null; |
453 | assert attributeName != null; |
454 | String result = attributes.getValue(attributeName); |
455 | |
456 | if (result == null) { |
457 | String message = localeTools.getMessage("errors.xml.attributeMustBeSpecified", attributeName); |
458 | |
459 | throw new SAXException(message); |
460 | } |
461 | return result; |
462 | } |
463 | |
464 | public void characters(char[] arg0, int arg1, int arg2) |
465 | throws SAXException { |
466 | // Do nothing. |
467 | } |
468 | |
469 | public void endDocument() |
470 | throws SAXException { |
471 | // Do nothing. |
472 | } |
473 | |
474 | public void endElement(String arg0, String arg1, String arg2) |
475 | throws SAXException { |
476 | // Do nothing. |
477 | } |
478 | |
479 | public void endPrefixMapping(String arg0) |
480 | throws SAXException { |
481 | // Do nothing. |
482 | } |
483 | |
484 | public void ignorableWhitespace(char[] arg0, int arg1, int arg2) |
485 | throws SAXException { |
486 | // Do nothing. |
487 | } |
488 | |
489 | public void processingInstruction(String arg0, String arg1) |
490 | throws SAXException { |
491 | // Do nothing. |
492 | } |
493 | |
494 | public void skippedEntity(String arg0) |
495 | throws SAXException { |
496 | // Do nothing. |
497 | } |
498 | |
499 | public void startDocument() |
500 | throws SAXException { |
501 | entries.clear(); |
502 | } |
503 | |
504 | public void startElement(String namespaceURI, String localElementName, |
505 | String qName, Attributes attributes) |
506 | throws SAXException { |
507 | if (localElementName.equals(ELEMENT_ARCHIVE)) { |
508 | String indexValue = getRequiredAttribute(attributes, ATTRIBUTE_INDEX); |
509 | String lastAccessedValue = getRequiredAttribute(attributes, ATTRIBUTE_LAST_ACCESSED); |
510 | String lastModifiedValue = getRequiredAttribute(attributes, ATTRIBUTE_LAST_MODIFIED); |
511 | String archivePath = getRequiredAttribute(attributes, ATTRIBUTE_SOURCE); |
512 | int index = Integer.parseInt(indexValue); |
513 | long lastAccessed = Long.parseLong(lastAccessedValue); |
514 | long lastModified = Long.parseLong(lastModifiedValue); |
515 | File archiveFile = new File(archivePath); |
516 | ArchiveCacheEntry entry = new ArchiveCacheEntry(archiveFile, lastAccessed, lastModified, index); |
517 | |
518 | putWithoutAccess(entry); |
519 | } else if (localElementName.equals(ELEMENT_ARCHIVE_CACHE)) { |
520 | String version = getRequiredAttribute(attributes, ATTRIBUTE_VERSION); |
521 | |
522 | if (!version.equals(EXPECTED_VERSION)) { |
523 | Object[] options = new Object[]{ |
524 | ATTRIBUTE_VERSION, |
525 | new Integer(EXPECTED_VERSION), |
526 | new Integer(version) |
527 | }; |
528 | String message = localeTools.getMessage("errors.xml.valueOfAttributeMustBeXButIsY", options); |
529 | |
530 | throw new SAXException(message); |
531 | } |
532 | } else { |
533 | String message = localeTools.getMessage("errors.xml.unknownElement", localElementName); |
534 | |
535 | throw new SAXException(message); |
536 | } |
537 | } |
538 | |
539 | public void startPrefixMapping(String arg0, String arg1) |
540 | throws SAXException { |
541 | // Do nothing. |
542 | } |
543 | } |
544 | } |