package org.ops4j.pax.web.service.jetty.ehcache; import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.Element; import org.apache.commons.logging.LogFactory; import org.mortbay.jetty.Server; import org.mortbay.jetty.handler.ContextHandler; import org.mortbay.jetty.servlet.AbstractSessionManager; import org.mortbay.util.LazyList; import org.ops4j.pax.web.service.spi.model.Model; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import java.io.*; import java.util.Collections; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * @author <a href="mailto:sgonzalez@atricore.org">Sebastian Gonzalez Oyuela</a> * @version $Id$ */ public class EHCacheSessionManager extends AbstractSessionManager { private static final org.apache.commons.logging.Log logger = LogFactory.getLog(EHCacheSessionManager.class); private CacheManager _cacheManager; private Cache _sessionsCache; private boolean _secureCookies; private ConcurrentHashMap _sessions; protected long _saveIntervalMillis = 100; //only persist changes to session access times every 60 secs public EHCacheSessionManager(Server server, Model model, Cache sessionsCache, boolean secureCookies) { this._sessionsCache = sessionsCache; this._secureCookies = secureCookies; } /** * Session * * Session instance in memory of this node. */ public class EHCacheSession extends AbstractSessionManager.Session { private final SessionData _data; private boolean _dirty=false; /** * Session from a request. * * @param request */ protected EHCacheSession (HttpServletRequest request) { super(request); _data = new SessionData(_clusterId); _data.setMaxIdleMs(_dftMaxIdleSecs*1000); _data.setCanonicalContext(canonicalize(_context.getContextPath())); _data.setVirtualHost(getVirtualHost(_context)); _data.setExpiryTime(_maxIdleMs < 0 ? 0 : (System.currentTimeMillis() + _maxIdleMs)); _values=_data.getAttributeMap(); } /** * Session restored in database. * @param data */ protected EHCacheSession (SessionData data) { super(data.getCreated(), data.getId()); _data=data; _data.setMaxIdleMs(_dftMaxIdleSecs*1000); _values=data.getAttributeMap(); } @Override protected Map newAttributeMap() { return _data.getAttributeMap(); } @Override public void setAttribute (String name, Object value) { super.setAttribute(name, value); _dirty=true; } @Override public void removeAttribute (String name) { super.removeAttribute(name); _dirty=true; } @Override protected void cookieSet() { _data.setCookieSet(_data.getAccessed()); } /** * Entry to session. * Called by SessionHandler on inbound request and the session already exists in this node's memory. * * @see AbstractSessionManager.Session#access(long) */ @Override protected void access(long time) { super.access(time); _data.setLastAccessed(_data.getAccessed()); _data.setAccessed(time); _data.setExpiryTime(_maxIdleMs < 0 ? 0 : (time + _maxIdleMs)); } /** * Exit from session * @see AbstractSessionManager.Session#complete() */ @Override protected void complete() { super.complete(); try { if (_dirty) { //The session attributes have changed, write to the db, ensuring //http passivation/activation listeners called willPassivate(); updateSession(_data); didActivate(); } else if ((_data.getAccessed() - _data.getLastSaved()) >= (getSaveInterval() )) { updateSessionAccessTime(_data); } } catch (Exception e) { logger.warn("Problem persisting changed session data id="+getId(), e); } finally { _dirty=false; } } @Override protected void timeout() throws IllegalStateException { if (logger.isDebugEnabled()) logger.debug("Timing out session id="+getClusterId()); super.timeout(); } // To provide visibility @Override protected void didActivate() { super.didActivate(); } @Override protected String getClusterId() { return super.getClusterId(); } @Override protected void willPassivate() { super.willPassivate(); } } /** * ClassLoadingObjectInputStream * * */ protected class ClassLoadingObjectInputStream extends ObjectInputStream { public ClassLoadingObjectInputStream(java.io.InputStream in) throws IOException { super(in); } public ClassLoadingObjectInputStream () throws IOException { super(); } @Override public Class resolveClass (java.io.ObjectStreamClass cl) throws IOException, ClassNotFoundException { try { return Class.forName(cl.getName(), false, Thread.currentThread().getContextClassLoader()); } catch (ClassNotFoundException e) { return super.resolveClass(cl); } } } /** * Set the time in milliseconds which is the interval between * saving the session access time to the database. * * This is an optimization that prevents the database from * being overloaded when a session is accessed very frequently. * * On session exit, if the session attributes have NOT changed, * the time at which we last saved the accessed * time is compared to the current accessed time. If the interval * is at least saveIntervalSecs, then the access time will be * persisted to the database. * * If any session attribute does change, then the attributes and * the accessed time are persisted. * * @param millis */ public void setSaveInterval (long millis) { _saveIntervalMillis =millis; } public long getSaveInterval () { return _saveIntervalMillis; } /** * A method that can be implemented in subclasses to support * distributed caching of sessions. This method will be * called whenever the session is written to the database * because the session data has changed. * * This could be used eg with a JMS backplane to notify nodes * that the session has changed and to delete the session from * the node's cache, and re-read it from the database. * @param session */ public void cacheInvalidate (Session session) { } /** * A session has been requested by it's id on this node. * * Load the session by id AND context path from the database. * Multiple contexts may share the same session id (due to dispatching) * but they CANNOT share the same contents. * * Check if last node id is my node id, if so, then the session we have * in memory cannot be stale. If another node used the session last, then * we need to refresh from the db. * * NOTE: this method will go to the database, so if you only want to check * for the existence of a Session in memory, use _sessions.get(id) instead. * * @see AbstractSessionManager#getSession(java.lang.String) */ @Override public EHCacheSession getSession(String idInCluster) { EHCacheSession session = (EHCacheSession)_sessions.get(idInCluster); synchronized (this) { try { //check if we need to reload the session - //as an optimization, don't reload on every access //to reduce the load on the database. This introduces a window of //possibility that the node may decide that the session is local to it, //when the session has actually been live on another node, and then //re-migrated to this node. This should be an extremely rare occurrence, //as load-balancers are generally well-behaved and consistently send //sessions to the same node, changing only iff that node fails. SessionData data = null; long now = System.currentTimeMillis(); if (logger.isDebugEnabled()) logger.debug("now="+now+ " lastSaved="+(session==null?0:session._data.getLastSaved())+ " interval="+(_saveIntervalMillis )+ " difference="+(now - (session==null?0:session._data.getLastSaved()))); if (session==null || ((now - session._data.getLastSaved()) >= (_saveIntervalMillis ))) { data = loadSession(idInCluster, canonicalize(_context.getContextPath()), getVirtualHost(_context)); } else { data = session._data; } if (data != null) { if (!data.getLastNode().equals(getIdManager().getWorkerName()) || session==null) { //if the session in the database has not already expired if (data.getExpiryTime() > System.currentTimeMillis()) { //session last used on a different node, or we don't have it in memory session = new EHCacheSession(data); _sessions.put(idInCluster, session); session.didActivate(); //TODO is this the best way to do this? Or do this on the way out using //the _dirty flag? updateSessionNode(data); } } else if (logger.isDebugEnabled()) logger.debug("Session not stale "+session._data); //session in db shares same id, but is not for this context } else { //No session in db with matching id and context path. session=null; if (logger.isDebugEnabled()) logger.debug("No session in database matching id="+idInCluster); } return session; } catch (Exception e) { logger.warn("Unable to load session from database", e); return null; } } } /** * Get all the sessions as a map of id to Session. */ @Override public Map getSessionMap() { return Collections.unmodifiableMap(_sessions); } /** * Get the number of sessions. * * @see AbstractSessionManager#getSessions() */ @Override public int getSessions() { int size = 0; synchronized (this) { size = _sessions.size(); } return size; } /** * Start the session manager. * * @see AbstractSessionManager#doStart() */ @Override public void doStart() throws Exception { if (_sessionIdManager == null) throw new IllegalStateException("No session id manager defined"); if (_sessionsCache == null) throw new IllegalStateException("No cache manager defined"); _sessions = new ConcurrentHashMap(); super.doStart(); } /** * Stop the session manager. * * @see AbstractSessionManager#doStop() */ @Override public void doStop() throws Exception { _sessions.clear(); _sessions = null; super.doStop(); } @Override protected void invalidateSessions() { //Do nothing - we don't want to remove and //invalidate all the sessions because this //method is called from doStop(), and just //because this context is stopping does not //mean that we should remove the session from //any other nodes } /** * Invalidate a session. * * @param idInCluster */ protected void invalidateSession (String idInCluster) { EHCacheSession session = null; if (logger.isDebugEnabled()) logger.debug("INVALIDATING session " + idInCluster); synchronized (this) { session = (EHCacheSession)_sessions.get(idInCluster); } if (session != null) { session.invalidate(); } } /** * Delete an existing session, both from the in-memory map and * the database. * * @see AbstractSessionManager#removeSession(java.lang.String) */ @Override protected void removeSession(String idInCluster) { EHCacheSession session = null; if (logger.isDebugEnabled()) logger.debug("REMOVING session " + idInCluster); synchronized (this) { session = (EHCacheSession)_sessions.remove(idInCluster); } try { if (session != null) deleteSession(session._data); } catch (Exception e) { logger.warn("Problem deleting session id="+idInCluster, e); } } /** * Add a newly created session to our in-memory list for this node and persist it. */ @Override protected void addSession(AbstractSessionManager.Session s) { if (s==null) return; EHCacheSession session = (EHCacheSession) s; if (logger.isDebugEnabled()) logger.debug("ADDING session " + s); synchronized (this) { _sessions.put(session.getClusterId(), session); } //TODO or delay the store until exit out of session? If we crash before we store it //then session data will be lost. try { session.willPassivate(); storeSession(((EHCacheSessionManager.EHCacheSession)session)._data); session.didActivate(); } catch (Exception e) { logger.warn("Unable to store new session id="+session.getId() , e); } } /** * Make a new Session. * * @see AbstractSessionManager#newSession(javax.servlet.http.HttpServletRequest) */ @Override protected AbstractSessionManager.Session newSession(HttpServletRequest request) { return new EHCacheSession(request); } /* ------------------------------------------------------------ */ /** Remove session from manager */ @Override public void removeSession(AbstractSessionManager.Session s, boolean invalidate) { // Remove session from context and global maps boolean removed = false; EHCacheSession session = (EHCacheSession) s; synchronized (this) { //take this session out of the map of sessions for this context if (getSession(session.getClusterId()) != null) { removed = true; removeSession(session.getClusterId()); } } if (removed) { // Remove session from all context and global id maps _sessionIdManager.removeSession(session); if (invalidate) _sessionIdManager.invalidateAll(session.getClusterId()); if (invalidate && _sessionListeners!=null) { HttpSessionEvent event=new HttpSessionEvent(session); for (int i= LazyList.size(_sessionListeners); i-->0;) ((HttpSessionListener)LazyList.get(_sessionListeners,i)).sessionDestroyed(event); } if (!invalidate) { session.willPassivate(); } } } /** * Expire any Sessions we have in memory matching the list of * expired Session ids. * * @param sessionIds */ protected void expire (List sessionIds) { //don't attempt to scavenge if we are shutting down if (isStopping() || isStopped()) return; //Remove any sessions we already have in memory that match the ids Thread thread=Thread.currentThread(); ClassLoader old_loader=thread.getContextClassLoader(); ListIterator itor = sessionIds.listIterator(); try { while (itor.hasNext()) { String sessionId = (String)itor.next(); if (logger.isDebugEnabled()) logger.debug("Expiring session id "+sessionId); EHCacheSession session = (EHCacheSession)_sessions.get(sessionId); if (session != null) { session.timeout(); itor.remove(); } else { if (logger.isDebugEnabled()) logger.debug("Unrecognized session id="+sessionId); } } } catch (Throwable t) { if (t instanceof ThreadDeath) throw ((ThreadDeath)t); else logger.warn("Problem expiring sessions", t); } finally { thread.setContextClassLoader(old_loader); } } /** * Load a session from the database * @param id * @return the session data that was loaded * @throws Exception */ protected SessionData loadSession (String id, String canonicalContextPath, String vhost) throws Exception { SessionData data = null; // String key = vhost + "_"+ canonicalContextPath + "_" + id; String key = id; if (logger.isDebugEnabled()) logger.debug("LOADING session : " + key); Element sessionElement = _sessionsCache.get(key); if (sessionElement != null) { data = (SessionData) sessionElement.getValue(); if (logger.isDebugEnabled()) logger.debug("LOADED session "+data); } return data; } /** * Insert a session into the database. * * @param data * @throws Exception */ protected void storeSession (SessionData data) throws Exception { if (data==null) return; if (logger.isDebugEnabled()) logger.debug("STORING session " + data); String rowId = calculateRowId(data); long now = System.currentTimeMillis(); String nodeId = getIdManager().getWorkerName(); data.setRowId(rowId); //set it on the in-memory data as well as in db data.setLastSaved(now); data.setLastNode(nodeId); // String key = data.getVirtualHost() + "_"+ data.getCanonicalContext() + "_" + data.getId(); String key = data.getId(); Element sessionElement = new Element(key, data); _sessionsCache.put(sessionElement); if (logger.isDebugEnabled()) logger.debug("STORED session "+data); } /** * Update data on an existing persisted session. * * @param data * @throws Exception */ protected void updateSession (SessionData data) throws Exception { if (data==null) return; if (logger.isDebugEnabled()) logger.debug("UPDATING session " + data); long now = System.currentTimeMillis(); String nodeId = getIdManager().getWorkerName(); data.setLastSaved(now); data.setLastNode(nodeId); // String key = data.getVirtualHost() + "_"+ data.getCanonicalContext() + "_" + data.getId(); String key = data.getId(); Element sessionElement = new Element(key, data); _sessionsCache.put(sessionElement); data.setLastSaved(now); if (logger.isDebugEnabled()) logger.debug("Updated session "+data); } /** * Update the node on which the session was last seen to be my node. * * @param data * @throws Exception */ protected void updateSessionNode (SessionData data) throws Exception { if (logger.isDebugEnabled()) logger.debug("UPDATE NODE session " + data); String nodeId = getIdManager().getWorkerName(); data.setLastNode(nodeId); // String key = data.getVirtualHost() + "_"+ data.getCanonicalContext() + "_" + data.getId(); String key = data.getId(); Element sessionElement = new Element(key, data); _sessionsCache.put(sessionElement); } /** * Persist the time the session was last accessed. * * @param data * @throws Exception */ private void updateSessionAccessTime (SessionData data) throws Exception { long now = System.currentTimeMillis(); String nodeId = getIdManager().getWorkerName(); data.setLastNode(nodeId); data.setLastSaved(now); // String key = data.getVirtualHost() + "_"+ data.getCanonicalContext() + "_" + data.getId(); String key = data.getId(); Element sessionElement = new Element(key, data); _sessionsCache.put(sessionElement); if (logger.isDebugEnabled()) logger.debug("Updated access time session id="+data.getId()); } /** * Delete a session from the database. Should only be called * when the session has been invalidated. * * @param data * @throws Exception */ protected void deleteSession (SessionData data) throws Exception { //String key = data.getVirtualHost() + "_"+ data.getCanonicalContext() + "_" + data.getId(); String key = data.getId(); _sessionsCache.remove(key); } /** * Calculate a unique id for this session across the cluster. * * Unique id is composed of: contextpath_virtualhost0_sessionid * @param data * @return */ private String calculateRowId (SessionData data) { String rowId = canonicalize(_context.getContextPath()); rowId = rowId + "_" + getVirtualHost(_context); rowId = rowId+"_"+data.getId(); return rowId; } /** * Get the first virtual host for the context. * * Used to help identify the exact session/contextPath. * * @return 0.0.0.0 if no virtual host is defined */ private String getVirtualHost (ContextHandler.SContext context) { String vhost = "0.0.0.0"; if (context==null) return vhost; String [] vhosts = context.getContextHandler().getVirtualHosts(); if (vhosts==null || vhosts.length==0 || vhosts[0]==null) return vhost; return vhosts[0]; } /** * Make an acceptable file name from a context path. * * @param path * @return */ private String canonicalize (String path) { if (path==null) return ""; return path.replace('/', '_').replace('.','_').replace('\\','_'); } }