001package com.identityworksllc.iiq.common.cache;
002
003import sailpoint.tools.Util;
004
005import java.io.ObjectStreamException;
006import java.io.Serializable;
007import java.util.*;
008import java.util.concurrent.ConcurrentHashMap;
009import java.util.concurrent.ConcurrentMap;
010import java.util.concurrent.TimeUnit;
011import java.util.function.BiConsumer;
012
013/**
014 * Implements a Cache that exposes itself as a regular Map. Cached entries will be removed
015 * from the map only when an operation would touch that entry, including all bulk
016 * operations (e.g. {@link #isEmpty()}).
017 *
018 * If you serialize this class, it will be replaced with a HashMap<K,V> snapshot in
019 * the serialization stream via {@link #writeReplace()}
020 *
021 * @param <K> The key type
022 * @param <V> The value type
023 */
024public class CacheMap<K, V> implements Map<K, V>, Serializable {
025
026        /**
027         * The cache map entry class that will be returned by getEntry()
028         */
029        protected class CacheMapEntry implements java.util.Map.Entry<K, V> {
030
031                /**
032                 * The key
033                 */
034                private final K key;
035
036                /**
037                 * The value
038                 */
039                private V value;
040
041                /**
042                 * Constructs a new map entry
043                 *
044                 * @param theKey The key value
045                 * @param theValue The value associated with the key
046                 */
047                public CacheMapEntry(K theKey, V theValue) {
048                        this.key = theKey;
049                        this.value = theValue;
050                }
051
052                @Override
053                public boolean equals(Object o) {
054                        if (this == o) return true;
055                        if (!(o instanceof Map.Entry)) return false;
056                        Map.Entry<K, V> that = (Map.Entry<K, V>) o;
057                        return Objects.equals(key, that.getKey()) && Objects.equals(value, that.getValue());
058                }
059
060                /**
061                 * @see java.util.Map.Entry#getKey()
062                 */
063                @Override
064                public K getKey() {
065                        return key;
066                }
067
068                /**
069                 * @see java.util.Map.Entry#getValue()
070                 */
071                @Override
072                public V getValue() {
073                        return value;
074                }
075
076                @Override
077                public int hashCode() {
078                        return Objects.hash(key, value);
079                }
080
081                /**
082                 * @see java.util.Map.Entry#setValue(java.lang.Object)
083                 */
084                @Override
085                public V setValue(V val) {
086                        CacheMap.this.put(key, val);
087                        V existing = value;
088                        this.value = val;
089                        return existing;
090                }
091        }
092
093        /**
094         * The wrapper entry set for this map type, which will be returned by entrySet().
095         */
096        protected class CacheMapEntrySet extends AbstractSet<CacheMapEntry> {
097                @Override
098                public Iterator<CacheMapEntry> iterator() {
099                        return internalMap.entrySet()
100                                        .stream()
101                                        .filter(e -> !e.getValue().isExpired())
102                                        .map(e -> new CacheMapEntry(e.getKey(), e.getValue().getValue()))
103                                        .iterator();
104                }
105
106                @Override
107                public int size() {
108                        return CacheMap.this.size();
109                }
110        }
111
112        /**
113         * Serialization UID
114         */
115        private static final long serialVersionUID = 8575644570548892660L;
116
117        /**
118         * Creates a before expiration hook
119         */
120        private BiConsumer<K, V> beforeExpirationHook;
121
122        /**
123         * The expiration time for a new entry in seconds
124         */
125        private final long expirationTimeSeconds;
126        
127        /**
128         * The internal map associated with this cache containing keys to cache entries
129         */
130        private final ConcurrentMap<K, CacheEntry<? extends V>> internalMap;
131
132        /**
133         * A class used to generate new values for get() if they don't exist already
134         */
135        private final CacheGenerator<? extends V> valueGenerator;
136
137        /**
138         * Constructs a new cache map with the default expiration time of 10 minutes
139         */
140        public CacheMap() {
141                this(10, TimeUnit.MINUTES, (CacheGenerator<? extends V>) null);
142        }
143
144        /**
145         * Constructs a new empty cache map with the given expiration time in the given units. The time will be converted internally to seconds.
146         *
147         * @param amount The amount of the given time unit before expiration
148         * @param type The time unit
149         */
150        public CacheMap(int amount, TimeUnit type) {
151                this(amount, type, (CacheGenerator<? extends V>) null);
152        }
153
154        /**
155         * Constructs a new empty cache map with the given expiratino time in the given units.
156         * Additionally, copies all values from the given Map into this CacheMap, setting
157         * their expiration as though they had just been inserted.
158         *
159         * @param amount The amount of the given time unit before expiration
160         * @param type The time unit
161         * @param input An arbitrary Map to copy into this CacheMap
162         */
163        public CacheMap(int amount, TimeUnit type, Map<? extends K, ? extends V> input) {
164                this(amount, type, (CacheGenerator<? extends V>) null);
165
166                if (input != null) {
167                        this.putAll(input);
168                }
169        }
170
171        /**
172         * Constructs a new empty cache map with the given expiration time in the given units.
173         * The time will be converted internally to seconds if the result is less than one
174         * second, then it will default to one second.
175         *
176         * @param amount The amount of the given time unit before expiration
177         * @param type The time unit
178         * @param generator The value generator to use if get() does not match a key (pass null for none)
179         */
180        public CacheMap(int amount, TimeUnit type, CacheGenerator<? extends V> generator) {
181                this.internalMap = new ConcurrentHashMap<>();
182
183                long secondsTime = TimeUnit.SECONDS.convert(amount, type);
184                if (secondsTime < 1) {
185                        secondsTime = 1;
186                }
187
188                this.expirationTimeSeconds = secondsTime;
189
190                if (this.expirationTimeSeconds > Integer.MAX_VALUE) {
191                        throw new IllegalArgumentException("Cache time cannot exceed " + Integer.MAX_VALUE + " seconds");
192                }
193                this.valueGenerator = generator;
194                this.beforeExpirationHook = this::beforeExpiration;
195        }
196
197        /**
198         * The default initial before expiration hook. By default, this does nothing, but subclasses
199         * can override it to do whatever they'd like.
200         *
201         * @param key The key being expired
202         * @param value The value being expired
203         */
204        protected void beforeExpiration(K key, V value) {
205                /* Do nothing by default */
206        }
207
208        /**
209         * Caches the given value with the default expiration seconds
210         *
211         * @param value The value to cache
212         * @return A cache entry representing this value
213         */
214        protected CacheEntry<V> cache(V value) {
215                return new CacheEntry<>(value, getNewExpirationDate());
216        }
217
218        /**
219         * @see java.util.Map#clear()
220         */
221        @Override
222        public void clear() {
223                internalMap.clear();
224        }
225
226        /**
227         * Returns true if the map contains the given key and it is not expired. If the map
228         * does not contain the key, and a value generator is defined, it will be invoked,
229         * causing this method to always return true.
230         *
231         * @see java.util.Map#containsKey(java.lang.Object)
232         */
233        @Override
234        public boolean containsKey(Object key) {
235                if (!internalMap.containsKey(key)) {
236                        if (valueGenerator != null) {
237                                V newValue = valueGenerator.getValue(key);
238                                put((K) key, newValue);
239                        }
240                }
241                return internalMap.containsKey(key) && !internalMap.get(key).isExpired();
242        }
243
244        /**
245         * @see java.util.Map#containsValue(java.lang.Object)
246         */
247        @Override
248        @SuppressWarnings("unchecked")
249        public boolean containsValue(Object value) {
250                invalidateRecords();
251                CacheEntry<V> val = cache((V) value);
252                return internalMap.containsValue(val);
253        }
254
255        /**
256         * @see java.util.Map#entrySet()
257         */
258        @Override
259        public Set<java.util.Map.Entry<K, V>> entrySet() {
260                return Collections.unmodifiableSet(new CacheMapEntrySet());
261        }
262
263        /**
264         * @see java.util.Map#get(java.lang.Object)
265         */
266        @SuppressWarnings("unchecked")
267        @Override
268        public V get(Object key) {
269                if (!containsKey(key)) {
270                        if (valueGenerator != null) {
271                                V newValue = valueGenerator.getValue(key);
272                                put((K) key, newValue);
273                        }
274                }
275                CacheEntry<? extends V> val = internalMap.get(key);
276                if (val == null || val.isExpired()) {
277                        internalMap.remove(key);
278                        return null;
279                }
280                return val.getValue();
281        }
282
283        /**
284         * Returns a new Date object offset from the current date by the default expiration duration in seconds
285         *
286         * @return The date of expiration starting now
287         */
288        protected Date getNewExpirationDate() {
289                return Util.incrementDateBySeconds(new Date(), (int)expirationTimeSeconds);
290        }
291
292        /**
293         * Return the number of seconds remaining for the cache entry of the
294         * given key. If the key is not present or the cache entry has expired,
295         * this method returns 0.0.
296         *
297         * @param key The key to check
298         * @return The number of seconds remaining
299         */
300        public double getSecondsRemaining(K key) {
301                CacheEntry<? extends V> cacheEntry = internalMap.get(key);
302                if (cacheEntry == null || cacheEntry.isExpired()) {
303                        return 0.0;
304                }
305
306                double time = (cacheEntry.getExpiration() - System.currentTimeMillis()) / 1000.0;
307                if (time < 0.01) {
308                        time = 0.0;
309                }
310                return time;
311        }
312
313        /**
314         * Invalidate all records in the internal storage that have expired
315         */
316        public void invalidateRecords() {
317                Set<K> toRemove = new HashSet<K>();
318                for (K key : internalMap.keySet()) {
319                        if (internalMap.get(key).isExpired()) {
320                                toRemove.add(key);
321                        }
322                }
323                for (K key : toRemove) {
324                        this.beforeExpirationHook.accept(key, internalMap.get(key).getValue());
325                        internalMap.remove(key);
326                }
327        }
328
329        /**
330         * @see java.util.Map#isEmpty()
331         */
332        @Override
333        public boolean isEmpty() {
334                invalidateRecords();
335                return internalMap.isEmpty();
336        }
337
338        /**
339         * @see java.util.Map#keySet()
340         */
341        @Override
342        public Set<K> keySet() {
343                invalidateRecords();
344                return internalMap.keySet();
345        }
346
347        /**
348         * @see java.util.Map#put(java.lang.Object, java.lang.Object)
349         */
350        @Override
351        public V put(K key, V value) {
352                CacheEntry<? extends V> val = internalMap.put(key, cache(value));
353                if (val == null || val.isExpired()) {
354                        return null;
355                }
356                return val.getValue();
357        }
358
359        /**
360         * @see java.util.Map#putAll(java.util.Map)
361         */
362        @Override
363        public void putAll(Map<? extends K, ? extends V> m) {
364                for (K key : m.keySet()) {
365                        put(key, m.get(key));
366                }
367        }
368
369        /**
370         * Puts all cache entries from another CacheMap into this one. They will be copied
371         * as CacheEntry objects, thus retaining their expiration timestamps. Expired entries
372         * will not be copied.
373         *
374         * @param other The other CacheMap object
375         */
376        public void putAll(CacheMap<? extends K, ? extends V> other) {
377                for (K key : other.keySet()) {
378                        CacheEntry<? extends V> entry = other.internalMap.get(key);
379                        if (entry != null && !entry.isExpired()) {
380                                internalMap.put(key, entry);
381                        }
382                }
383        }
384
385        /**
386         * @see java.util.Map#remove(java.lang.Object)
387         */
388        @Override
389        public V remove(Object key) {
390                CacheEntry<? extends V> val = internalMap.remove(key);
391                if (val == null || val.isExpired()) {
392                        return null;
393                }
394                return val.getValue();
395        }
396
397        /**
398         * @see java.util.Map#size()
399         */
400        @Override
401        public int size() {
402                invalidateRecords();
403                return internalMap.size();
404        }
405
406        /**
407         * Returns a snapshot of this cache at this moment in time as a java.util.HashMap. The
408         * snapshot will not be updated to reflect any future expirations of cache data.
409         *
410         * @return A snapshot of this cache
411         */
412        public HashMap<K, V> snapshot() {
413                invalidateRecords();
414                HashMap<K, V> replacement = new HashMap<>();
415                for( K key : keySet() ) {
416                        replacement.put(key, get(key));
417                }
418                return replacement;
419        }
420
421        /**
422         * @see java.util.Map#values()
423         */
424        @Override
425        public Collection<V> values() {
426                invalidateRecords();
427                List<V> values = new ArrayList<V>();
428                for (CacheEntry<? extends V> val : internalMap.values()) {
429                        if (val != null && !val.isExpired()) {
430                                values.add(val.getValue());
431                        }
432                }
433                return values;
434        }
435
436        /**
437         * Adds the given hook function as a pre-expiration hook. It will be chained to any
438         * existing hooks using {@link BiConsumer#andThen(BiConsumer)}.
439         *
440         * @param hook The new hook
441         * @return This object, for chaining or static construction
442         */
443        public CacheMap<K, V> withBeforeExpirationHook(BiConsumer<K, V> hook) {
444                if (hook == null) {
445                        throw new IllegalArgumentException("Cannot pass a null hook");
446                }
447                if (this.beforeExpirationHook == null) {
448                        this.beforeExpirationHook = hook;
449                } else {
450                        this.beforeExpirationHook = this.beforeExpirationHook.andThen(hook);
451                }
452
453                return this;
454        }
455        
456        /**
457         * This method, called on serialization, will replace this cache with a static HashMap. This will allow this class to be used in remote EJB calls, etc.
458         * 
459         * See here: https://docs.oracle.com/javase/6/docs/platform/serialization/spec/output.html#5324
460         * 
461         * @return A static HashMap based on this class
462         * @throws ObjectStreamException if a failure occurs (part of the Serializable spec)
463         */
464        @SuppressWarnings("unused")
465        private Object writeReplace() throws ObjectStreamException {
466                return snapshot();
467        }
468}