package com.limegroup.gnutella.library; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.limewire.collection.Comparators; import org.limewire.concurrent.ExecutorsHelper; import org.limewire.inject.EagerSingleton; import org.limewire.io.IOUtils; import org.limewire.lifecycle.Service; import org.limewire.lifecycle.ServiceRegistry; import org.limewire.listener.EventListener; import org.limewire.util.CommonUtils; import org.limewire.util.ConverterObjectInputStream; import org.limewire.util.GenericsUtils; import com.google.inject.Inject; import com.limegroup.gnutella.MediaTypeAggregator; import com.limegroup.gnutella.URN; import com.limegroup.gnutella.messages.QueryRequest; /** * This class contains a systemwide File creation time cache that persists these * times across sessions. Very similar to UrnCache but less complex. * <p> * This class is needed because we don't want to consult File.lastModifiedTime() * all the time. We want to preserve creation times across the Gnutella network. * <p> * In order to be speedy, this class maintains two data structures - one for * fast URN to creation time lookup, another for fast 'youngest' file lookup. * <p> * IMPLEMENTATION NOTES: * <p> * The two data structures do not reflect each other's internal representation - * specifically, the URN->Time lookup may have more URNs than the Time->URNSet * lookup. This is a consequence of partial file sharing. It is the case that * the URNs in the sets of the Time->URNSet lookup are a subset of the URNs in * the URN->Time lookup. For more details, see addTime and commitTime. */ @EagerSingleton public class CreationTimeCache { private static final Log LOG = LogFactory.getLog(CreationTimeCache.class); /** * File where creation times for files are stored. */ private final File CTIME_CACHE_FILE = new File(CommonUtils.getUserSettingsDir(), "createtimes.cache"); /** * Whether or not data is dirty since the last time we saved. */ private volatile boolean dirty = false; private final ExecutorService deserializeQueue = ExecutorsHelper .newProcessingQueue("CreationTimeCacheDeserializer"); private final Library library; private final FileView gnutellaFileView; private final Future<Maps> deserializer; @Inject CreationTimeCache(Library library, @GnutellaFiles FileView gnutellaFileView) { this.gnutellaFileView = gnutellaFileView; this.library = library; this.deserializer = deserializeQueue.submit(new Callable<Maps>() { public Maps call() throws Exception { Map<URN, Long> urnToTime = createMap(); SortedMap<Long, Set<URN>> timeToUrn = constructURNMap(urnToTime); return new Maps(urnToTime, timeToUrn); } }); } @Inject void register(ServiceRegistry registry) { registry.register(new Service() { @Override public String getServiceName() { return "What's New Manager"; } @Override public void initialize() { CreationTimeCache.this.initialize(); } @Override public void start() { } @Override public void stop() { } }); } void initialize() { library.addManagedListStatusListener(new EventListener<LibraryStatusEvent>() { @Override public void handleEvent(LibraryStatusEvent event) { handleManagedListStatusEvent(event); } }); // TODO Currently creation time cache is used to get creation times for // CoreLocalFileItem. // By only registering events for gnutella share list changes in // CreationTimeCache, the creation time field for Non gnutella shared // files is being set to -1. // In the future we will probably want to register for events on the // managed fileList instead gnutellaFileView.addListener(new EventListener<FileViewChangeEvent>() { @Override public void handleEvent(FileViewChangeEvent event) { handleFileListEvent(event); } }); } /** * Package private for testing. */ Map<URN, Long> getUrnToTime() { return getMaps().getUrnToTime(); } /** * Package private for testing. */ SortedMap<Long, Set<URN>> getTimeToUrn() { return getMaps().getTimeToUrn(); } private Maps getMaps() { boolean interrupted = false; try { while (true) { try { return deserializer.get(); } catch (InterruptedException tryAgain) { interrupted = true; } } } catch (ExecutionException e) { throw new RuntimeException(e); } finally { if (interrupted) Thread.currentThread().interrupt(); } } /** Returns the number of URNS stored. */ public synchronized int getSize() { return getUrnToTime().size(); } /** * Get the Creation Time of the file. * * @param urn <tt>URN</tt> to look up Creation Time for * @return A Long that represents the creation time of the urn. Null if * there is no association. */ public synchronized Long getCreationTime(URN urn) { return getUrnToTime().get(urn); } /** * Get the Creation Time of the file. * * @param urn <tt>URN</tt> to look up Creation Time for * @return A long that represents the creation time of the urn. -1 if no * time exists. */ public long getCreationTimeAsLong(URN urn) { Long l = getCreationTime(urn); if (l == null) return -1; else return l.longValue(); } /** * Removes the CreationTime that is associated with the specified URN. */ synchronized void removeTime(URN urn) { Long time = getUrnToTime().remove(urn); removeURNFromURNSet(urn, time); if (time != null) dirty = true; } /** * Clears away any URNs for files that do not exist anymore. * * @param shouldClearURNSetMap true if you want to clear TIME_TO_URNSET_MAP * too */ private void pruneTimes(boolean shouldClearURNSetMap) { synchronized (this) { Iterator<Map.Entry<URN, Long>> iter = getUrnToTime().entrySet().iterator(); while (iter.hasNext()) { Map.Entry<URN, Long> currEntry = iter.next(); URN currURN = currEntry.getKey(); Long cTime = currEntry.getValue(); // check to see if file still exists // NOTE: technically a URN can map to multiple FDs, but I only // want // to know about one. getFileDescForUrn prefers FDs over iFDs. FileDesc fd = gnutellaFileView.getFileDesc(currURN); if ((fd == null) || (fd.getFile() == null) || !fd.getFile().exists()) { dirty = true; iter.remove(); if (shouldClearURNSetMap) removeURNFromURNSet(currURN, cTime); } } } } /** * Clears away any URNs for files that do not exist anymore. */ private void pruneTimes() { pruneTimes(true); } /** * Add a CreationTime for the specified <tt>URN</tt> instance. Can be called * for any type of file (complete or partial). Partial files should be * committed upon completion via commitTime. * * @param urn the <tt>URN</tt> instance containing Time to store * @param time The creation time of the urn. * @throws IllegalArgumentException If urn is null or time is invalid. */ public synchronized void addTime(URN urn, long time) throws IllegalArgumentException { if (urn == null) throw new IllegalArgumentException("Null URN."); if (time <= 0) throw new IllegalArgumentException("Bad Time = " + time); Long cTime = Long.valueOf(time); // populate urn to time Long existing = getUrnToTime().get(urn); if (existing == null || !existing.equals(cTime)) { dirty = true; getUrnToTime().put(urn, cTime); } } /** * Commits the CreationTime for the specified <tt>URN</tt> instance. Should * be called for complete files that are shared. addTime() for the input URN * should have been called first (otherwise you'll get a * IllegalArgumentException) * * @param urn the <tt>URN</tt> instance containing Time to store * @throws IllegalArgumentException If urn is null or the urn was never * added in addTime(); */ public synchronized void commitTime(URN urn) throws IllegalArgumentException { if (urn == null) throw new IllegalArgumentException("Null URN."); Long cTime = getUrnToTime().get(urn); if (cTime == null) throw new IllegalArgumentException("Never added URN via addTime()"); // populate time to set of urns Set<URN> urnSet = getTimeToUrn().get(cTime); if (urnSet == null) { urnSet = new HashSet<URN>(); // purposely not a UrnSet -- we need to have multiple SHA1s in the list. getTimeToUrn().put(cTime, urnSet); } urnSet.add(urn); } /** * Returns an List of URNs, from 'youngest' to 'oldest'. * * @param max the maximum number of URNs you want returned. if you want all, * give Integer.MAX_VALUE. * @return a List ordered by younger URNs. */ public Collection<URN> getFiles(final int max) throws IllegalArgumentException { return getFiles(null, max); } /** * Returns an List of URNs, from 'youngest' to 'oldest'. * * @param request in case the query has meta-flags, you can give it to me. * null is fine though. * @param max the maximum number of URNs you want returned. if you want all, * give Integer.MAX_VALUE. * @return a List ordered by younger URNs. */ public Collection<URN> getFiles(final QueryRequest request, final int max) throws IllegalArgumentException { synchronized (this) { if (max < 1) throw new IllegalArgumentException("bad max = " + max); MediaTypeAggregator.Aggregator filter = request == null ? null : MediaTypeAggregator .getAggregator(request); // may be non-null at loop end List<URN> toRemove = null; Set<URN> urnList = new LinkedHashSet<URN>(); // we bank on the fact that the TIME_TO_URNSET_MAP iterator returns // the // entries in descending order.... for (Set<URN> urns : getTimeToUrn().values()) { if (urnList.size() >= max) { break; } for (URN currURN : urns) { if (urnList.size() >= max) { break; } // we only want shared FDs FileDesc fd = gnutellaFileView.getFileDesc(currURN); if (fd == null) { if (toRemove == null) { toRemove = new ArrayList<URN>(); } toRemove.add(currURN); continue; } if (filter == null || filter.allow(fd.getFileName())) { urnList.add(currURN); } } } // clear any ifd's or unshared files that may have snuck into // structures if (toRemove != null) { for (URN currURN : toRemove) { removeTime(currURN); } } return urnList; } } /** * Returns all of the files URNs, from youngest to oldest. */ public Collection<URN> getFiles() { return getFiles(Integer.MAX_VALUE); } /** * Write cache so that we only have to calculate them once. */ synchronized void persistCache() { if (!dirty) return; // It's not ideal to hold a lock while writing to disk, but I doubt // think // it's a problem in practice. ObjectOutputStream oos = null; try { oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream( CTIME_CACHE_FILE))); oos.writeObject(getUrnToTime()); } catch (IOException e) { LOG.error("Unable to write creation cache", e); } finally { IOUtils.close(oos); } dirty = false; } /** * Evicts the urn from the TIME_TO_URNSET_MAP. * * @param refTime if is non-null, will try to eject from set referred to by * refTime. Otherwise will do an iterative search. */ private synchronized void removeURNFromURNSet(URN urn, Long refTime) { if (refTime != null) { Set<URN> urnSet = getTimeToUrn().get(refTime); if (urnSet != null && urnSet.remove(urn)) if (urnSet.size() < 1) getTimeToUrn().remove(refTime); } else { // search everything // find the urn in the map: // 1) get rid of it // 2) get rid of the empty set if it exists for (Iterator<Set<URN>> i = getTimeToUrn().values().iterator(); i.hasNext();) { Set<URN> urnSet = i.next(); if (urnSet.contains(urn)) { urnSet.remove(urn); // 1) if (urnSet.size() < 1) i.remove(); // 2) break; } } } } /** * Constructs the TIME_TO_URNSET_MAP, which is based off the entries in the * URN_TO_TIME_MAP. */ private SortedMap<Long, Set<URN>> constructURNMap(Map<URN, Long> urnToTime) { SortedMap<Long, Set<URN>> timeToUrn = new TreeMap<Long, Set<URN>>(Comparators .inverseLongComparator()); for (Map.Entry<URN, Long> currEntry : urnToTime.entrySet()) { // for each entry, get the creation time and the urn.... Long cTime = currEntry.getValue(); URN urn = currEntry.getKey(); // put the urn in a set of urns that have that creation time.... Set<URN> urnSet = timeToUrn.get(cTime); if (urnSet == null) { urnSet = new HashSet<URN>(); // purposely not a UrnSet -- we need multiple SHA1s in the list // populate the reverse mapping timeToUrn.put(cTime, urnSet); } urnSet.add(urn); } return timeToUrn; } /** * Loads values from cache file, if available. */ Map<URN, Long> createMap() { if (!CTIME_CACHE_FILE.exists()) { dirty = true; return new HashMap<URN, Long>(); } ObjectInputStream ois = null; try { ois = new ConverterObjectInputStream(new BufferedInputStream(new FileInputStream( CTIME_CACHE_FILE))); Map<URN, Long> map = GenericsUtils.scanForMap(ois.readObject(), URN.class, Long.class, GenericsUtils.ScanMode.REMOVE); return map; } catch (Throwable t) { dirty = true; LOG.error("Unable to read creation time file", t); return new HashMap<URN, Long>(); } finally { IOUtils.close(ois); } } private static class Maps { /** URN -> Creation Time (Long) */ private final Map<URN, Long> urnToTime; /** Creation Time (Long) -> Set of URNs */ private final SortedMap<Long, Set<URN>> timeToUrn; Maps(Map<URN, Long> urnToTime, SortedMap<Long, Set<URN>> timeToUrn) { this.urnToTime = urnToTime; this.timeToUrn = timeToUrn; } public SortedMap<Long, Set<URN>> getTimeToUrn() { return timeToUrn; } public Map<URN, Long> getUrnToTime() { return urnToTime; } } private void fileAdded(FileDesc fd) { URN sha1 = fd.getSHA1Urn(); if (!LibraryUtils.isForcedShare(fd) && sha1 != null) { synchronized (this) { Long cTime = getCreationTime(sha1); if (cTime == null) { cTime = Long.valueOf(fd.lastModified()); } // if cTime is non-null but 0, then the IO subsystem is // letting us know that the file was FNF or an IOException // occurred - the best course of action is to // ignore the issue and not add it to the CTC, hopefully // we'll get a correct reading the next time around... if (cTime.longValue() > 0) { // these calls may be superfluous but are quite fast.... addTime(sha1, cTime.longValue()); commitTime(sha1); } } } } private void fileChanged(URN oldUrn, URN newUrn) { // re-populate the ctCache synchronized (this) { long creationTime = getCreationTimeAsLong(oldUrn); removeTime(oldUrn); if (creationTime != -1) { addTime(newUrn, creationTime); commitTime(newUrn); } } } /** * Listens for events from the FileManager. */ private void handleManagedListStatusEvent(LibraryStatusEvent evt) { switch (evt.getType()) { case LOAD_FINISHING: pruneTimes(); break; case SAVE: persistCache(); break; } } private void handleFileListEvent(FileViewChangeEvent evt) { switch (evt.getType()) { case FILE_META_CHANGED: // fallthrough & pretend this was an add incase this was the URN // meta notification -- no big if it doesn't exist. case FILE_ADDED: // Commit the time in the CreationTimeCache, but don't share // the installer. fileAdded(evt.getFileDesc()); break; case FILE_REMOVED: if(evt.getFileDesc().getSHA1Urn() != null) { removeTime(evt.getFileDesc().getSHA1Urn()); } break; case FILE_CHANGED: if(evt.getOldValue().getSHA1Urn() == null) { fileAdded(evt.getFileDesc()); } else if(evt.getFileDesc().getSHA1Urn() != null) { fileChanged(evt.getOldValue().getSHA1Urn(), evt.getFileDesc().getSHA1Urn()); } else { removeTime(evt.getOldValue().getSHA1Urn()); } break; } } }