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}