diff --git a/src/main/java/org/apache/commons/collections/map/PassiveExpiringMap.java b/src/main/java/org/apache/commons/collections/map/PassiveExpiringMap.java new file mode 100644 index 000000000..c0184cffa --- /dev/null +++ b/src/main/java/org/apache/commons/collections/map/PassiveExpiringMap.java @@ -0,0 +1,577 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.commons.collections.map; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Decorates a Map to evict expired entries once their expiration + * time has been reached. + *

+ * When putting a key-value pair in the map this decorator uses a + * {@link ExpirationPolicy} to determine how long the entry should remain alive + * as defined by an expiration time value. + *

+ *

+ * When accessing the mapped value for a key, its expiration time is checked, + * and if it is a negative value or if it is greater than the current time, the + * mapped value is returned. Otherwise, the key is removed from the decorated + * map, and null is returned. + *

+ *

+ * When invoking methods that involve accessing the entire map contents (i.e + * {@link #containsKey(Object)}, {@link #entrySet()}, etc.) this decorator + * removes all expired entries prior to actually completing the invocation. + *

+ *

+ * Note that {@link PassiveExpiringMap} is not synchronized and is not + * thread-safe. If you wish to use this map from multiple threads + * concurrently, you must use appropriate synchronization. The simplest approach + * is to wrap this map using {@link java.util.Collections#synchronizedMap(Map)}. + * This class may throw exceptions when accessed by concurrent threads without + * synchronization. + *

+ * + * @param + * the type of the keys in the map + * + * @param + * the type of the values in the map + * + * @since 4.0 + * @version $Id: $ + */ +public class PassiveExpiringMap extends AbstractMapDecorator + implements Serializable { + + /** + * A {@link ExpirationPolicy} that returns a expiration time that is a + * constant about of time in the future from the current time. + * + * @param + * the type of the keys in the map + * @param + * the type of the values in the map + * + * @since 4.0 + * @version $Id: $ + */ + public static class ConstantTimeToLiveExpirationPolicy implements + ExpirationPolicy { + + /** Serialization version */ + private static final long serialVersionUID = 1L; + + /** the constant time-to-live value measured in milliseconds. */ + private final long timeToLiveMillis; + + /** + * Default constructor. Constructs a policy using a negative + * time-to-live value that results in entries never expiring. + */ + public ConstantTimeToLiveExpirationPolicy() { + this(-1L); + } + + /** + * Construct a policy with the given time-to-live constant measured in + * milliseconds. A negative time-to-live value indicates entries never + * expire. A zero time-to-live value indicates entries expire (nearly) + * immediately. + * + * @param timeToLiveMillis + * the constant amount of time (in milliseconds) an entry is + * available before it expires. A negative value results in + * entries that NEVER expire. A zero value results in entries + * that ALWAYS expire. + */ + public ConstantTimeToLiveExpirationPolicy(long timeToLiveMillis) { + super(); + this.timeToLiveMillis = timeToLiveMillis; + } + + /** + * Construct a policy with the given time-to-live constant measured in + * the given time unit of measure. + * + * @param timeToLive + * the constant amount of time an entry is available before + * it expires. A negative value results in entries that NEVER + * expire. A zero value results in entries that ALWAYS + * expire. + * @param timeUnit + * the unit of time for the timeToLive + * parameter, must not be null. + * @throws IllegalArgumentException + * if the time unit is null. + */ + public ConstantTimeToLiveExpirationPolicy(long timeToLive, + TimeUnit timeUnit) { + this(validateAndConvertToMillis(timeToLive, TimeUnit.MILLISECONDS)); + } + + /** + * Determine the expiration time for the given key-value entry. + * + * @param key + * the key for the entry (ignored). + * @param value + * the value for the entry (ignored). + * @return if {@link #timeToLiveMillis} ≥ 0, an expiration time of + * {@link #timeToLiveMillis} + + * {@link System#currentTimeMillis()} is returned. Otherwise, -1 + * is returned indicating the entry never expires. + */ + public long expirationTime(K key, V value) { + if (timeToLiveMillis >= 0L) { + // avoid numerical overflow + long now = System.currentTimeMillis(); + if (now > Long.MAX_VALUE - timeToLiveMillis) { + // expiration would be greater than Long.MAX_VALUE + // never expire + return -1; + } + + // timeToLiveMillis in the future + return now + timeToLiveMillis; + } + + // never expire + return -1L; + } + } + + /** + * A policy to determine the expiration time for key-value entries. + * + * @param + * the key object type. + * @param + * the value object type + * + * @since 4.0 + * @version $Id: $ + */ + public static interface ExpirationPolicy extends Serializable { + /** + * Determine the expiration time for the given key-value entry. + * + * @param key + * the key for the entry. + * @param value + * the value for the entry. + * @return the expiration time value measured in milliseconds. A + * negative return value indicates the entry never expires. + */ + long expirationTime(K key, V value); + } + + /** Serialization version */ + private static final long serialVersionUID = 1L; + + /** + * First validate the input parameters. If the parameters are valid, convert + * the given time measured in the given units to the same time measured in + * milliseconds. If the parameters are invalid, an + * {@link IllegalArgumentException} is thrown. + * + * @param timeToLive + * the constant amount of time an entry is available before it + * expires. A negative value results in entries that NEVER + * expire. A zero value results in entries that ALWAYS expire. + * @param timeUnit + * the unit of time for the timeToLive parameter, + * must not be null. + * @throws IllegalArgumentException + * if the time unit is null. + */ + private static long validateAndConvertToMillis(long timeToLive, + TimeUnit timeUnit) { + if (timeUnit == null) { + throw new IllegalArgumentException("Time unit must not be null"); + } + return timeUnit.convert(timeToLive, TimeUnit.MILLISECONDS); + } + + /** map used to manage expiration times for the actual map entries. */ + private final Map expirationMap = new HashMap(); + + /** the policy used to determine time-to-live values for map entries. */ + private final ExpirationPolicy expiringPolicy; + + /** + * Default constructor. Constructs a map decorator that results in entries + * NEVER expiring. + */ + public PassiveExpiringMap() { + this(-1L); + } + + /** + * Construct a map decorator using the given expiration policy to determine + * expiration times. + * + * @param expiringPolicy + * the policy used to determine expiration times of entries as + * they are added. + */ + public PassiveExpiringMap(ExpirationPolicy expiringPolicy) { + this(expiringPolicy, new HashMap()); + } + + /** + * Construct a map decorator that decorates the given map and uses the given + * expiration policy to determine expiration times. If there are any + * elements already in the map being decorated, they will NEVER expire + * unless they are replaced. + * + * @param expiringPolicy + * the policy used to determine expiration times of entries as + * they are added. + * @param map + * the map to decorate, must not be null. + * @throws IllegalArgumentException + * if the map is null. + */ + public PassiveExpiringMap(ExpirationPolicy expiringPolicy, + Map map) { + super(map); + if (expiringPolicy == null) { + throw new IllegalArgumentException("Policy must not be null."); + } + this.expiringPolicy = expiringPolicy; + } + + /** + * Construct a map decorator that decorates the given map using the given + * time-to-live value measured in milliseconds to create and use a + * {@link ConstantTimeToLiveExpirationPolicy} expiration policy. + * + * @param timeToLiveMillis + * the constant amount of time (in milliseconds) an entry is + * available before it expires. A negative value results in + * entries that NEVER expire. A zero value results in entries + * that ALWAYS expire. + */ + public PassiveExpiringMap(long timeToLiveMillis) { + this(new ConstantTimeToLiveExpirationPolicy(timeToLiveMillis), + new HashMap()); + } + + /** + * Construct a map decorator using the given time-to-live value measured in + * milliseconds to create and use a + * {@link ConstantTimeToLiveExpirationPolicy} expiration policy. If there + * are any elements already in the map being decorated, they will NEVER + * expire unless they are replaced. + * + * @param timeToLiveMillis + * the constant amount of time (in milliseconds) an entry is + * available before it expires. A negative value results in + * entries that NEVER expire. A zero value results in entries + * that ALWAYS expire. + * @param map + * the map to decorate, must not be null. + * @throws IllegalArgumentException + * if the map is null. + */ + public PassiveExpiringMap(long timeToLiveMillis, Map map) { + this(new ConstantTimeToLiveExpirationPolicy(timeToLiveMillis), + map); + } + + /** + * Construct a map decorator using the given time-to-live value measured in + * the given time units of measure to create and use a + * {@link ConstantTimeToLiveExpirationPolicy} expiration policy. + * + * @param timeToLive + * the constant amount of time an entry is available before it + * expires. A negative value results in entries that NEVER + * expire. A zero value results in entries that ALWAYS expire. + * @param timeUnit + * the unit of time for the timeToLive parameter, + * must not be null. + * @throws IllegalArgumentException + * if the time unit is null. + */ + public PassiveExpiringMap(long timeToLive, TimeUnit timeUnit) { + this(validateAndConvertToMillis(timeToLive, timeUnit)); + } + + /** + * Construct a map decorator that decorates the given map using the given + * time-to-live value measured in the given time units of measure to create + * {@link ConstantTimeToLiveExpirationPolicy} expiration policy. This policy + * is used to determine expiration times. If there are any elements already + * in the map being decorated, they will NEVER expire unless they are + * replaced. + * + * @param timeToLive + * the constant amount of time an entry is available before it + * expires. A negative value results in entries that NEVER + * expire. A zero value results in entries that ALWAYS expire. + * @param timeUnit + * the unit of time for the timeToLive parameter, + * must not be null. + * @throws IllegalArgumentException + * if the time unit is null. + * @param map + * the map to decorate, must not be null. + * @throws IllegalArgumentException + * if the map is null. + */ + public PassiveExpiringMap(long timeToLive, TimeUnit timeUnit, Map map) { + this(validateAndConvertToMillis(timeToLive, timeUnit), map); + } + + /** + * Constructs a map decorator that decorates the given map and results in + * entries NEVER expiring. If there are any elements already in the map + * being decorated, they also will NEVER expire. + * + * @param map + * the map to decorate, must not be null. + * @throws IllegalArgumentException + * if the map is null. + */ + public PassiveExpiringMap(Map map) { + this(-1L, map); + } + + /** + * Normal {@link Map#clear()} behavior with the addition of clearing all + * expiration entries as well. + */ + @Override + public void clear() { + super.clear(); + expirationMap.clear(); + } + + /** + * All expired entries are removed from the map prior to determining the + * contains result. + */ + @Override + public boolean containsKey(Object key) { + removeIfExpired(key, now()); + return super.containsKey(key); + } + + /** + * All expired entries are removed from the map prior to determining the + * contains result. + */ + @Override + public boolean containsValue(Object value) { + removeAllExpired(now()); + return super.containsValue(value); + } + + /** + * All expired entries are removed from the map prior to returning the entry + * set. + */ + @Override + public Set> entrySet() { + removeAllExpired(now()); + return super.entrySet(); + } + + /** + * All expired entries are removed from the map prior to returning the entry + * value. + */ + @Override + public V get(Object key) { + removeIfExpired(key, now()); + return super.get(key); + } + + /** + * All expired entries are removed from the map prior to determining if it + * is empty. + */ + @Override + public boolean isEmpty() { + removeAllExpired(now()); + return super.isEmpty(); + } + + /** + * Determines if the given expiration time is less than now + * + * @param now + * the time in milliseconds used to compare against the + * expiration time. + * @param expirationTimeObject + * the expiration time value retrieved from + * {@link #expirationMap}, can be null. + * @return true if expirationTimeObject is ≥ 0 + * and expirationTimeObject < now. + * false otherwise. + */ + private boolean isExpired(long now, Long expirationTimeObject) { + if (expirationTimeObject != null) { + long expirationTime = expirationTimeObject.longValue(); + return (expirationTime >= 0 && now >= expirationTime); + } + return false; + } + + /** + * All expired entries are removed from the map prior to returning the key + * set. + */ + @Override + public Set keySet() { + removeAllExpired(now()); + return super.keySet(); + } + + /** + * The current time in milliseconds. + */ + private long now() { + return System.currentTimeMillis(); + } + + @Override + public V put(K key, V value) { + return put(key, value, now()); + } + + /** + * Add the given key-value pair to this map as well as recording the entry's + * expiration time based on the current time in milliseconds, + * now and this map's {@link #expiringPolicy}. + */ + private V put(K key, V value, long now) { + // record expiration time of new entry + long expirationTime = expiringPolicy.expirationTime(key, value); + expirationMap.put(key, Long.valueOf(expirationTime)); + + return super.put(key, value); + } + + @Override + public void putAll(Map mapToCopy) { + for (Map.Entry entry : mapToCopy.entrySet()) { + put(entry.getKey(), entry.getValue()); + } + } + + /** + * Normal {@link Map#remove(Object)} behavior with the addition of removing + * any expiration entry as well. + */ + @Override + public V remove(Object key) { + expirationMap.remove(key); + return super.remove(key); + } + + /** + * Removes all entries in the map whose expiration time is less than + * now. The exceptions are entries with negative expiration + * times; those entries are never removed. + * + * @see #isExpired(long, Long) + */ + private void removeAllExpired(long now) { + Iterator> iter = expirationMap.entrySet() + .iterator(); + while (iter.hasNext()) { + Map.Entry expirationEntry = iter.next(); + if (isExpired(now, expirationEntry.getValue())) { + // remove entry from collection + super.remove(expirationEntry.getKey()); + // remove entry from expiration map + iter.remove(); + } + } + } + + /** + * Removes the entry with the given key if the entry's expiration time is + * less than now. If the entry has a negative expiration time, + * the entry is never removed. + */ + private void removeIfExpired(Object key, long now) { + Long expirationTimeObject = expirationMap.get(key); + if (isExpired(now, expirationTimeObject)) { + remove(key); + } + } + + /** + * All expired entries are removed from the map prior to returning the size. + */ + @Override + public int size() { + removeAllExpired(now()); + return super.size(); + } + + /** + * Read the map in using a custom routine. + * + * @param in + * the input stream + * @throws IOException + * @throws ClassNotFoundException + */ + @SuppressWarnings("unchecked") + // (1) should only fail if input stream is incorrect + private void readObject(ObjectInputStream in) throws IOException, + ClassNotFoundException { + in.defaultReadObject(); + map = (Map) in.readObject(); // (1) + } + + /** + * Write the map out using a custom routine. + * + * @param out + * the output stream + * @throws IOException + */ + private void writeObject(ObjectOutputStream out) throws IOException { + out.defaultWriteObject(); + out.writeObject(map); + } + + /** + * All expired entries are removed from the map prior to returning the value + * collection. + */ + @Override + public Collection values() { + removeAllExpired(now()); + return super.values(); + } +} diff --git a/src/test/java/org/apache/commons/collections/map/TestPassiveExpiringMap.java b/src/test/java/org/apache/commons/collections/map/TestPassiveExpiringMap.java new file mode 100644 index 000000000..97462d809 --- /dev/null +++ b/src/test/java/org/apache/commons/collections/map/TestPassiveExpiringMap.java @@ -0,0 +1,207 @@ +package org.apache.commons.collections.map; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import junit.framework.Test; + +import org.apache.commons.collections.BulkTest; +import org.apache.commons.collections.map.PassiveExpiringMap.ExpirationPolicy; + +public class TestPassiveExpiringMap extends AbstractTestMap { + + private static class TestExpirationPolicy implements + ExpirationPolicy { + + private static final long serialVersionUID = 1L; + + public long expirationTime(Integer key, String value) { + // odd keys expire immediately, even keys never expire + if (key == null) { + return 0; + } + + if (key.intValue() % 2 == 0) { + return -1; + } + + return 0; + } + } + + public static Test suite() { + return BulkTest.makeSuite(TestPassiveExpiringMap.class); + } + + public TestPassiveExpiringMap(String testName) { + super(testName); + } + + // public void testCreate() throws Exception { + // writeExternalFormToDisk((java.io.Serializable) makeObject(), + // "PassiveExpiringMap.emptyCollection.version4.obj"); + // + // writeExternalFormToDisk((java.io.Serializable) makeFullMap(), + // "PassiveExpiringMap.fullCollection.version4.obj"); + // } + + @Override + public String getCompatibilityVersion() { + return "4"; + } + + private Map makeDecoratedTestMap() { + Map m = new HashMap(); + m.put(Integer.valueOf(1), "one"); + m.put(Integer.valueOf(2), "two"); + m.put(Integer.valueOf(3), "three"); + m.put(Integer.valueOf(4), "four"); + m.put(Integer.valueOf(5), "five"); + m.put(Integer.valueOf(6), "six"); + return new PassiveExpiringMap( + new TestExpirationPolicy(), m); + } + + @Override + public Map makeObject() { + return new PassiveExpiringMap(); + } + + private Map makeTestMap() { + Map m = new PassiveExpiringMap( + new TestExpirationPolicy()); + m.put(Integer.valueOf(1), "one"); + m.put(Integer.valueOf(2), "two"); + m.put(Integer.valueOf(3), "three"); + m.put(Integer.valueOf(4), "four"); + m.put(Integer.valueOf(5), "five"); + m.put(Integer.valueOf(6), "six"); + return m; + } + + public void testConstructors() { + try { + Map map = null; + new PassiveExpiringMap(map); + fail("constructor - exception should have been thrown."); + } catch (IllegalArgumentException ex) { + // success + } + + try { + ExpirationPolicy policy = null; + new PassiveExpiringMap(policy); + fail("constructor - exception should have been thrown."); + } catch (IllegalArgumentException ex) { + // success + } + + try { + TimeUnit unit = null; + new PassiveExpiringMap(10L, unit); + fail("constructor - exception should have been thrown."); + } catch (IllegalArgumentException ex) { + // success + } + } + + public void testContainsKey() { + Map m = makeTestMap(); + assertFalse(m.containsKey(Integer.valueOf(1))); + assertFalse(m.containsKey(Integer.valueOf(3))); + assertFalse(m.containsKey(Integer.valueOf(5))); + assertTrue(m.containsKey(Integer.valueOf(2))); + assertTrue(m.containsKey(Integer.valueOf(4))); + assertTrue(m.containsKey(Integer.valueOf(6))); + } + + public void testContainsValue() { + Map m = makeTestMap(); + assertFalse(m.containsValue("one")); + assertFalse(m.containsValue("three")); + assertFalse(m.containsValue("five")); + assertTrue(m.containsValue("two")); + assertTrue(m.containsValue("four")); + assertTrue(m.containsValue("six")); + } + + public void testDecoratedMap() { + // entries shouldn't expire + Map m = makeDecoratedTestMap(); + assertEquals(6, m.size()); + assertEquals("one", m.get(Integer.valueOf(1))); + + // removing a single item shouldn't affect any other items + assertEquals("two", m.get(Integer.valueOf(2))); + m.remove(Integer.valueOf(2)); + assertEquals(5, m.size()); + assertEquals("one", m.get(Integer.valueOf(1))); + assertNull(m.get(Integer.valueOf(2))); + + // adding a single, even item shouldn't affect any other items + assertNull(m.get(Integer.valueOf(2))); + m.put(Integer.valueOf(2), "two"); + assertEquals(6, m.size()); + assertEquals("one", m.get(Integer.valueOf(1))); + assertEquals("two", m.get(Integer.valueOf(2))); + + // adding a single, odd item (one that expires) shouldn't affect any + // other items + // put the entry expires immediately + m.put(Integer.valueOf(1), "one-one"); + assertEquals(5, m.size()); + assertNull(m.get(Integer.valueOf(1))); + assertEquals("two", m.get(Integer.valueOf(2))); + } + + public void testEntrySet() { + Map m = makeTestMap(); + assertEquals(3, m.entrySet().size()); + } + + public void testGet() { + Map m = makeTestMap(); + assertNull(m.get(Integer.valueOf(1))); + assertEquals("two", m.get(Integer.valueOf(2))); + assertNull(m.get(Integer.valueOf(3))); + assertEquals("four", m.get(Integer.valueOf(4))); + assertNull(m.get(Integer.valueOf(5))); + assertEquals("six", m.get(Integer.valueOf(6))); + } + + public void testIsEmpty() { + Map m = makeTestMap(); + assertFalse(m.isEmpty()); + + // remove just evens + m = makeTestMap(); + m.remove(Integer.valueOf(2)); + m.remove(Integer.valueOf(4)); + m.remove(Integer.valueOf(6)); + assertTrue(m.isEmpty()); + } + + public void testKeySet() { + Map m = makeTestMap(); + assertEquals(3, m.keySet().size()); + } + + public void testSize() { + Map m = makeTestMap(); + assertEquals(3, m.size()); + } + + public void testValues() { + Map m = makeTestMap(); + assertEquals(3, m.values().size()); + } + + public void testZeroTimeToLive() { + // item should not be available + PassiveExpiringMap m = new PassiveExpiringMap( + 0L); + m.put("a", "b"); + assertNull(m.get("a")); + } +}