/*
* Copyright 2010 Glencoe Software, Inc. All rights reserved.
* Use is subject to license terms supplied in LICENSE.txt
*/
package ome.services.sessions.state;
import java.sql.Timestamp;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Ehcache;
import ome.conditions.ApiUsageException;
import ome.conditions.RemovedSessionException;
import ome.conditions.SessionTimeoutException;
import ome.model.meta.Session;
import ome.services.messages.DestroySessionMessage;
import ome.services.sessions.SessionCallback;
import ome.services.sessions.SessionContext;
import ome.services.sessions.SessionManager;
import ome.services.sessions.SessionManagerImpl;
import ome.services.sessions.events.UserGroupUpdateEvent;
import ome.system.OmeroContext;
import org.perf4j.StopWatch;
import org.perf4j.slf4j.Slf4JStopWatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.MapMaker;
/**
* Synchronized and lockable state for the {@link SessionManager}. Maps from
* {@link Session} uuid to {@link SessionContext} in memory, with each mapping
* also having an additional cache which may spill over to disk,
* {@link StaleCacheListener listeners}.
*
* Uses {@link MapMaker} and various implementations from
* java.util.concurrent.atomic to provide a lock-free implementation.
*
*
* @author Josh Moore, josh at glencoesoftware.com
* @since 4.2.1
* @see <a href="http://trac.openmicroscopy.org/ome/ticket/3173">ticket:3173</a>
*/
public class SessionCache implements ApplicationContextAware {
private final static Logger log = LoggerFactory.getLogger(SessionCache.class);
/**
* Observer pattern used to refresh sessions in doUpdate.
*/
public interface StaleCacheListener {
/**
* Method called for every active session in the cache. The returned
* {@link SessionContext} will be used to replace the current one.
*
* Any runtime exception can be thrown to show that an update is not
* possible.
*/
SessionContext reload(SessionContext context);
}
/**
* Container which can be put in a single {@link AtomicReference} instance.
* Contains all the data for a single session immutably. Therefore any
* thread that manages to get access to this instance can work with this
* data even if another thread is currently in the process of removing this
* from the map.
*/
private static class Data {
public final static Integer MAX_ERROR = 3;
/**
* Count of errors which occur during {@link SessionCache#doUpdate()}.
* These are not copied from previous instances since if a new Data
* is created, then it is valid for the error count to be reset. The
* intended effect of counting errors is that a sporadic DB exception
* on {@link StaleCacheListener#reload(SessionContext)} does not remove
* a perfectly valid session. Instead, the error must occur multiple
* times.
*/
final AtomicInteger error = new AtomicInteger(0);
final SessionContext sessionContext;
final long lastAccessTime;
final long hitCount;
/**
* Initial creation of a Data instance when a new session is
* added to the cache.
*/
Data(SessionContext sc) {
this(sc, System.currentTimeMillis(), 1);
}
/**
* Copy constructor which uses the current time for {@link #lastAccessTime} and
* increments {@link #hitCount} by one. Used when updating the access
* time for a session.
*/
Data(Data old) {
this(old, true);
}
/**
* Copy constructor which uses either the current time for {@link #lastAccessTime}
* (if reset is true) or the previous lastAccessTime (if reset is false); and
* increments {@link #hitCount} by one. Used when reloading the session.
*/
Data(Data old, boolean reset) {
this(old, old.sessionContext, reset);
}
/**
* Like {@link Data#Data(Data, boolean)} but allows setting the
* {@link SessionContext} which should be stored in the new instance.
* This is used on reload. See {@link SessionCache#doUpdate()}.
* @param old
* @param ctx
* @param reset
*/
Data(Data old, SessionContext ctx, boolean reset) {
this(ctx, reset ? System.currentTimeMillis() : old.lastAccessTime, old.hitCount+1);
}
Data(SessionContext sc, long last, long count) {
this.sessionContext = sc;
this.lastAccessTime = last;
this.hitCount = count;
// clear context
sc.getSession().getDetails().setContexts(null);
}
}
/**
* Container which can be put in a single {@link AtomicReference} instance
* to hold all the state of the session cache instance immutably.
*
* Similar to {@link Data}, any thread which manages to get access to a
* {@link State} instance may act on it, even if a new instance is activated
* in the background.
*/
private static class State {
/**
* Time of the last update. This will be updated by a background thread.
*/
final long lastUpdateRun;
/**
* Time of the last update request. Most likely occurs via
* SessionManagerImpl.onApplicationEvent(). Initialized to <em>before</em>
* {@link #lastUpdateRun} to prevent initial blocking.
*/
final long lastUpdateRequest;
/**
* Initial creation of State, used on cache creation.
*/
State() {
this.lastUpdateRun = System.currentTimeMillis();
this.lastUpdateRequest = this.lastUpdateRun - 1;
}
/**
* Update constructor for State, which is used when a new update request
* is received by the cache.
*
* Specifies that a new request has occurred, but the old run
* is kept.
*/
State(State old, long request) {
this.lastUpdateRun = old.lastUpdateRun;
this.lastUpdateRequest = request;
}
/**
* Whether or not {@link #doUpdate()} should run. Returns immediately
* if {@link #active} contains true.
*/
boolean checkNeedsUpdate(long forceUpdateInterval) {
// Check whether entry is required.
if (lastUpdateRun < 0) {
// Then we are currently running
return false;
}
if (lastUpdateRun <= lastUpdateRequest) {
return true;
}
long timed = System.currentTimeMillis() - forceUpdateInterval;
if (lastUpdateRun <= timed) {
return true;
}
return false;
}
}
/**
*
*/
private final Map<String, Data> sessions;
/**
*
*/
private final AtomicReference<State> state = new AtomicReference<State>(new State());
/**
* Time in milliseconds between updates. Can be set via
* {@link #setUpdateInterval(long)} but has a non-null value just in case
* (30 minutes)
*/
private long forceUpdateInterval = 1800000;
/**
* Injected {@link CacheManager} used to create various caches.
*/
private CacheManager ehmanager;
/**
*
*/
private final Map<String, Set<SessionCallback>> sessionCallbackMap;
private final AtomicReference<StaleCacheListener> staleCacheListener = new AtomicReference<StaleCacheListener>();
/**
* Whether or not {@link #doUpdate()} is currently running.
*/
private final AtomicBoolean active = new AtomicBoolean();
/**
* {@link OmeroContext} instance used to publish
* {@link DestroySessionMessage} on {@link #removeSession(String)}
*/
private OmeroContext context;
public SessionCache() {
final MapMaker mapMaker = new MapMaker();
sessions = mapMaker.makeMap();
sessionCallbackMap = mapMaker.makeMap();
}
/**
* Injection method, also performs the creation of {@link #sessions}
*/
public void setCacheManager(CacheManager manager) {
this.ehmanager = manager;
}
/**
* Context injector.
*/
public void setApplicationContext(ApplicationContext ctx)
throws BeansException {
context = (OmeroContext) ctx;
}
/**
* Inject time in milliseconds between updates.
*/
public void setUpdateInterval(long milliseconds) {
this.forceUpdateInterval = milliseconds;
}
// Accessors
// ========================================================================
public void setStaleCacheListener(StaleCacheListener staleCacheListener) {
this.staleCacheListener.set(staleCacheListener);
}
public boolean addSessionCallback(String session, SessionCallback cb) {
synchronized (sessionCallbackMap) {
Set<SessionCallback> set = sessionCallbackMap.get(session);
if (set == null) {
set = new HashSet<SessionCallback>();
sessionCallbackMap.put(session, set);
}
return set.add(cb);
}
}
public boolean removeSessionCallback(String session, SessionCallback cb) {
return null != sessionCallbackMap.remove(session);
}
// State management
// ========================================================================
// These methods are currently the only access to the sessions cache, and
// are responsible for synchronization and the update mechanism.
/**
* Puts a session blindly into the context. This does nothing to a context
* which was previously present (e.g. call internalRemove, etc.) and
* therefore usage should be proceeded by a check.
*/
public void putSession(String uuid, SessionContext sessionContext) {
Data data = new Data(sessionContext);
this.sessions.put(uuid, data);
final StopWatch sw = new Slf4JStopWatch("omero.session");
addSessionCallback(uuid, new SessionCallback.SimpleCloseCallback(){
public void close() {
sw.stop();
}});
}
/**
* Used externally to refresh the {@link SessionContext} instance
* associated with the session uuid
* @param uuid
* @param replacement
*/
public void refresh(String uuid, SessionContext replacement) {
Data data = getDataNullOrThrowOnTimeout(uuid, true);
refresh(uuid, data, replacement);
}
/**
*
* @param uuid
* @param data
* @param replacement
*/
private void refresh(String uuid, Data data, SessionContext replacement) {
// Adding and upping hit information.
Data fresh = new Data(data, replacement, false);
this.sessions.put(uuid, fresh);
}
/**
* Retrieve a session possibly raising either
* {@link RemovedSessionException} or {@link SessionTimeoutException}.
*/
public SessionContext getSessionContext(String uuid) {
return getSessionContext(uuid, false);
}
/**
* Retrieve a session possibly raising either
* {@link RemovedSessionException} or {@link SessionTimeoutException}.
*
* @param quietly If true, then the access time for the given UUID
* will not be updated.
*/
public SessionContext getSessionContext(String uuid, boolean quietly) {
if (uuid == null) {
throw new ApiUsageException("Uuid cannot be null.");
}
Data data = getDataNullOrThrowOnTimeout(uuid, true);
if (!quietly) {
// Up'ing access time
this.sessions.put(uuid, new Data(data));
}
return data.sessionContext;
}
/**
* Returns all the data contained in the internal implementation of
* this manger.
*
* @param quietly If true, then the access time for the given UUID
* will not be updated.
*/
public Map<String, Object> getSessionData(String uuid, boolean quietly) {
if (uuid == null) {
throw new ApiUsageException("Uuid cannot be null.");
}
Data data = getDataNullOrThrowOnTimeout(uuid, true);
if (!quietly) {
// Up'ing access time
this.sessions.put(uuid, new Data(data));
}
return new ImmutableMap.Builder<String, Object>()
.put("class", getClass().getName())
.put("sessionContext", data.sessionContext)
.put("hitCount", data.hitCount)
.put("lastAccessTime", data.lastAccessTime)
// .put("error", data.error.get())
.build();
}
/**
* Gets the {@link SessionContext} without upping its access information and
* instead checks the current values for timeouts. Can optionally return
* null or throw an exception.
*
* @param uuid
* @param strict
* If true, an exception will be raised on timeout; otherwise
* null returned.
* @return
*/
private Data getDataNullOrThrowOnTimeout(String uuid, boolean strict) {
//
// All times are in milliseconds
//
// Getting quiet so that we have the previous access and hit info
final Data data = this.sessions.get(uuid);
if (data == null) {
// Previously we called internalRemove here, under the
// assumption that some other thread/event could cause the
// element to be set to null. That's no longer allowed
// and will only occur by a call to internalRemove,
// making that call unneeded.
if (strict) {
throw new RemovedSessionException("No context for " + uuid);
} else {
return null;
}
}
long lastAccess = data.lastAccessTime;
long hits = data.hitCount;
// Get session info
SessionContext ctx = data.sessionContext;
long now = System.currentTimeMillis();
long start = ctx.getSession().getStarted().getTime();
long timeToIdle = ctx.getSession().getTimeToIdle();
long timeToLive = ctx.getSession().getTimeToLive();
// If never accessed, used creation time
if (lastAccess == 0) {
lastAccess = start;
}
// Calculated
long alive = now - start;
long idle = now - lastAccess;
// Do comparisons if timeTo{} is non-0
if (0 < timeToLive && timeToLive < alive) {
String reason = reason("timeToLive", lastAccess, hits, start,
timeToLive, (alive - timeToLive));
if (strict) {
throw new SessionTimeoutException(reason, ctx);
} else {
return null;
}
} else if (0 < timeToIdle && timeToIdle < idle) {
String reason = reason("timeToIdle", lastAccess, hits, start,
timeToIdle, (idle - timeToIdle));
if (strict) {
throw new SessionTimeoutException(reason, ctx);
} else {
return null;
}
}
return data;
}
private String reason(String why, long lastAccess, long hits, long start,
long setting, long exceeded) {
return String.format("Session (started=%s, hits=%s, last access=%s) "
+ "exceeded %s (%s) by %s ms", new Timestamp(start), hits,
new Timestamp(lastAccess), why, setting, exceeded);
}
public void removeSession(String uuid) {
internalRemove(uuid, "Remove session called");
}
private void internalRemove(String uuid, String reason) {
if (!sessions.containsKey(uuid)) {
log.warn("Session not in cache: " + uuid);
return; // EARLY EXIT!
}
log.info("Destroying session " + uuid + " due to : " + reason);
// Announce to all callbacks.
Set<SessionCallback> cbs = sessionCallbackMap.get(uuid);
if (cbs != null) {
for (SessionCallback cb : cbs) {
try {
cb.close();
} catch (Exception e) {
final String msg = "SessionCallback %s throw exception for session %s";
log.warn(String.format(msg, cb, uuid), e);
}
}
}
// Announce to all listeners
try {
context.publishEvent(new DestroySessionMessage(this, uuid));
} catch (RuntimeException re) {
final String msg = "Session listener threw an exception for session %s";
log.warn(String.format(msg, uuid), re);
}
ehmanager.removeCache("memory:" + uuid);
ehmanager.removeCache("ondisk:" + uuid);
sessions.remove(uuid);
}
/**
* Since all methods which use {@link #getIds()} will subsequently check for
* the existing session, we do not block here. Blocking is primarily useful
* for post-admintype changes which can add or remove a user from a group.
* The existence of a session (which is what getIds specifies) is not
* significantly effected.
*/
public Set<String> getIds() {
return sessions.keySet();
}
// State
// =========================================================================
public Ehcache inMemoryCache(String uuid) {
// Check to make sure exists
getDataNullOrThrowOnTimeout(uuid, true);
String key = "memory:" + uuid;
return createCache(key, true, Integer.MAX_VALUE);
}
public Ehcache onDiskCache(String uuid) {
// Check to make sure exists
getDataNullOrThrowOnTimeout(uuid, true);
String key = "ondisk:" + uuid;
return createCache(key, false, 100);
}
protected Ehcache createCache(String key, boolean inMemory, int maxInMemory) {
Ehcache cache = null;
try {
cache = ehmanager.getEhcache(key);
} catch (Exception e) {
// ok
}
if (cache == null) {
CacheFactory factory = new CacheFactory();
factory.setBeanName(key);
factory.setCacheManager(ehmanager);
factory.setOverflowToDisk(!inMemory);
factory.setMaxElementsInMemory(maxInMemory);
factory.setMaxElementsOnDisk(0);
factory.setDiskPersistent(false);
factory.setTimeToIdle(0);
factory.setTimeToLive(0);
cache = factory.createCache();
}
return cache;
}
// Update
// =========================================================================
// Primarily used for testing.
public long getLastUpdated() {
return state.get().lastUpdateRun;
}
/**
* Adds a new entry to {@link #state}. If the
* timestamp on the event is invalid, then
* {@link System#currentTimeMillis()} will be used.
*/
public void updateEvent(UserGroupUpdateEvent ugue) {
long time = 0;
if (ugue == null || ugue.getTimestamp() > System.currentTimeMillis()) {
time = System.currentTimeMillis();
} else {
time = ugue.getTimestamp();
}
State old = state.get();
state.set(new State(old, time));
}
/**
* Will only ever be accessed by a single thread. Rechecks the target update
* time again in case a second write thread was blocking the current one.
* {@link #active} gets set to <code>true</code> value to specify that this
* method is currently running.
*/
public void doUpdate() {
// Check whether entry is required.
if (!state.get().checkNeedsUpdate(forceUpdateInterval)) {
return;
}
// Prevent recursion!
// ------------------
// To prevent another call from entering this block it's
// necessary to set active
if (!active.compareAndSet(false, true)) {
return;
}
try {
final Set<String> ids = sessions.keySet();
log.info("Synchronizing session cache. Count = " + ids.size());
final StopWatch sw = new Slf4JStopWatch();
for (String id : ids) {
reload(id);
}
sw.stop("omero.sessions.synchronization");
log.info(String.format("Synchronization took %s ms.",
sw.getElapsedTime()));
} catch (Exception e) {
log.error("Error synchronizing cache", e);
} finally {
active.set(false);
}
}
/**
* Provides the reloading logic of the {@link SessionCache} for the
* {@link SessionManagerImpl} to use.
*
* @see <a href="http://trac.openmicroscopy.org/ome/ticket/4011">ticket:4011</a>
* @see <a href="http://trac.openmicroscopy.org/ome/ticket/5849">ticket:5849</a>
*/
public void reload(String id) {
final StaleCacheListener listener = staleCacheListener.get();
if (listener == null) {
log.error("Null stale cache listener!");
return;
}
Data data = null;
try {
data = getDataNullOrThrowOnTimeout(id, false);
if (data == null) {
internalRemove(id, "Timeout");
return;
}
} catch (Exception e) {
// If an exception occurs here, then something is wrong
// with the Data instance itself since no DB calls are
// made. Therefore the instance will be removed.
log.warn("Removing session on get error of " + id, e);
internalRemove(id, "Get error");
}
try {
SessionContext ctx = data.sessionContext;
// May throw an exception
SessionContext replacement = listener.reload(ctx);
if (replacement == null) {
internalRemove(id, "Replacement null");
} else {
refresh(id, data, replacement);
}
} catch (Exception e) {
// If an exception occurs it MAY be transient, therefore
// we count the number of errors that have happened for
// this specific instance as described under Data#errors
// just to be safe.
int count = data.error.incrementAndGet();
if (count > Data.MAX_ERROR) {
log.warn("Removing session on reload error of " + id, e);
internalRemove(id, "Reload error");
} else {
log.warn(count + "error(s) on reload of " + id, e);
}
}
}
}