IuCachedValue.java

/*
 * Copyright © 2025 Indiana University
 * All rights reserved.
 *
 * BSD 3-Clause License
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * - Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * - Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package edu.iu;

import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.time.Duration;
import java.util.Timer;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Holds a single value by {@link SoftReference} with timed expiration.
 * 
 * <p>
 * A cached value could be cleared by the garbage collector in order to prevent
 * {@link OutOfMemoryError} due to insufficient heap space.
 * </p>
 * 
 * @param <V> value type
 */
public class IuCachedValue<V> {

	private static final Logger LOG = Logger.getLogger(IuCachedValue.class.getName());

	private static final Object NULL = new Object();
	private static final Timer PURGE_TIMER = new Timer("iu-cache-purge", true);
	private static final ReferenceQueue<Object> REFQ = new ReferenceQueue<>();

	private static class Ref extends SoftReference<Object> {
		private final IuCachedValue<?> cachedValue;

		private Ref(Object referent, IuCachedValue<?> cachedValue) {
			super(referent == null ? NULL : referent, REFQ);
			this.cachedValue = cachedValue;
		}
	}

	static {
		PURGE_TIMER.schedule(new TimerTask() {
			@Override
			public void run() {
				Ref ref;
				while ((ref = (Ref) REFQ.poll()) != null)
					ref.cachedValue.clear();
			}
		}, 500L, 500L);
	}

	private final long expires;
	private volatile UnsafeRunnable onExpire;
	private volatile TimerTask expireTask;
	private volatile Ref reference;
	private volatile boolean expired;

	/**
	 * Constructor.
	 * 
	 * @param value      value
	 * @param timeToLive maximum length of time for the cached value to remain valid
	 * @param onExpire   thunk to invoke when the reference expires; <em>should</em>
	 *                   execute quickly, i.e., to remove a map entry relative the
	 *                   provided key
	 */
	public IuCachedValue(V value, Duration timeToLive, UnsafeRunnable onExpire) {
		final var ttl = timeToLive.toMillis();
		this.reference = new Ref(value, this);
		this.expires = System.currentTimeMillis() + ttl;
		this.onExpire = onExpire;

		PURGE_TIMER.schedule(expireTask = new TimerTask() {
			@Override
			public void run() {
				clear();
			}
		}, ttl);
	}

	/**
	 * Gets the cached value.
	 * 
	 * @return cached value; may be null if the cached value is null, the reference
	 *         was cleared by the garbage collector, or the expiration time is in
	 *         the past.
	 */
	@SuppressWarnings("unchecked")
	public V get() {
		final var reference = ref();
		if (reference == null)
			return null;

		final var value = reference.get();
		if (value == NULL)
			return null;
		else
			return (V) value;
	}

	/**
	 * Determines whether or not the cached value is still valid.
	 * 
	 * @return true if the reference is still valid; false if it has been cleared by
	 *         the garbage collection or the expiration time is in the past.
	 */
	public boolean isValid() {
		return ref() != null;
	}

	/**
	 * Determines if an object is equal to the referent.
	 * 
	 * @param o object
	 * @return true if the reference is still {@link #isValid() valid} and the
	 *         referent is equal to the object.
	 */
	public boolean has(Object o) {
		final var reference = ref();
		if (reference == null)
			return false;

		final var value = reference.get();
		if (value == NULL)
			return o == null;
		else
			return IuObject.equals(value, o);
	}

	/**
	 * Invalidates the cached value, invokes the onExpire thunk, and clears all
	 * related references and resource.
	 * 
	 * <p>
	 * This method has no effect if invoked on an invalid reference.
	 * </p>
	 */
	public synchronized void clear() {
		if (!expired)
			try {
				expireTask.cancel();
				reference.clear();
				onExpire.run();
			} catch (Throwable e) {
				LOG.log(Level.INFO, e, () -> "Unhandled error in cache reference expiration thunk " + onExpire);
			} finally {
				expired = true;
				expireTask = null;
				reference = null;
				onExpire = null;
			}
	}

	@Override
	public int hashCode() {
		return IuObject.hashCode(get());
	}

	@Override
	public boolean equals(Object obj) {
		if (!IuObject.typeCheck(this, obj))
			return false;

		final var other = (IuCachedValue<?>) obj;
		return isValid() //
				&& other.isValid() //
				&& IuObject.equals(get(), other.get());
	}

	private Ref ref() {
		if (expires < System.currentTimeMillis()) {
			clear();
			return null;
		}

		final var reference = this.reference;
		if (reference == null)
			return null;

		final var value = reference.get();
		if (value == null) {
			clear();
			return null;
		} else
			return reference;
	}

}