/**
* Copyright 2012 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.apphosting.runtime.jetty9;
import static com.google.appengine.repackaged.com.google.common.io.BaseEncoding.base64Url;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.LogRecord;
import com.google.apphosting.api.DeadlineExceededException;
import com.google.apphosting.runtime.SessionData;
import com.google.apphosting.runtime.SessionStore;
import org.eclipse.jetty.server.session.AbstractSession;
import org.eclipse.jetty.server.session.AbstractSessionManager;
import org.eclipse.jetty.server.session.HashSessionIdManager;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionIdListener;
/**
* Implements the Jetty {@link AbstractSessionManager} and, as an
* inner class, {@link HashSessionIdManager} for our context. The
* session manager has to check the provided {@link SessionStore SessionStores}
* to find sessions.
*
*/
public class SessionManager extends AbstractSessionManager {
private static final Logger logger =
Logger.getLogger(SessionManager.class.getName());
static final String SESSION_PREFIX = "_ahs";
/**
* To reduce our datastore put time, we only consider a session
* dirty on access if it is at least 25% expired. So a session
* that expires in 1 hr will only be re-stored every 15 minutes,
* unless a "real" attribute change occurs.
*/
public static final double UPDATE_TIMESTAMP_RATIO = 0.75;
/* This is just useful for testing, and cheap to hold... */
private static String lastId = null;
/**
* Specializes {@link HashSessionIdManager}, featuring strong session ids.
*/
// Ludo: not sure that this session id generator is any better than the
// AbstractSessionIdManager's generation algorithm, which is also based on the
// SecureRandom class.
public static class SessionIdManager extends HashSessionIdManager {
public SessionIdManager() {
super(new SecureRandom());
}
/**
* Computes a new session key.
* @return a string representing the session id, effectively unique to
* this session
*/
@Override
public String newSessionId(HttpServletRequest request, long created) {
return generateNewId();
}
@Override
public String newSessionId(long seedTerm) {
return generateNewId();
}
public String generateNewId () {
byte randomBytes[] = new byte[16];
_random.nextBytes(randomBytes);
// Use a web-safe encoding in case the session identifier gets
// passed via a URL path parameter.
String id = base64Url().omitPadding().encode(randomBytes);
lastId = id;
logger.fine("Created a random session identifier: " + id);
return id;
}
}
/**
* A session implementation using the provided list of
* {@link SessionStore SessionStores} to store attributes. Expiration is
* also stored.
* <p>
* An instance of this class may be used simultaneously be multiple
* request threads. We use synchronization to guard access to sessionData
* and the parent object state.
*
*/
public class AppEngineSession extends AbstractSession {
private final SessionData sessionData;
private String key;
private volatile boolean dirty;
/**
* Create a new brand new session for the specified request. This
* constructor saves the new session to the datastore and
* memcache.
*/
public AppEngineSession(HttpServletRequest request) {
super(SessionManager.this, request);
this.sessionData = createSession(getId());
key = SESSION_PREFIX + getId();
dirty = false;
}
/**
* Create an object for an existing session
*/
public AppEngineSession(long created, long accessed,
String sessionId, SessionData sessionData) {
super(SessionManager.this, created, accessed, sessionId);
this.sessionData = sessionData;
key = SESSION_PREFIX + sessionId;
dirty = false;
}
public boolean isDirty() {
return dirty;
}
@Override
public void renewId(HttpServletRequest request) {
String oldId = getClusterId();
//remove session with the old from storage
deleteSession();
// generate a new id
String newClusterId = ((SessionIdManager)getSessionManager().getSessionIdManager()).newSessionId(request.hashCode());
String newNodeId = ((SessionIdManager)getSessionManager().getSessionIdManager()).getNodeId(newClusterId, request);
//change the ids and recreate the key
setClusterId(newClusterId);
setNodeId(newNodeId);
key = SESSION_PREFIX + newClusterId;
//save the session with the new id
save(true);
setIdChanged(true);
((SessionManager)getSessionManager()).callSessionIdListeners(this, oldId);
}
public void save() {
save(false);
}
protected void save (boolean force)
{
logger.fine("Session " + getId() + "is"+(dirty?" dirty":" not dirty")+(force || dirty ? " saving":" not saving"));
//save if it is dirty or its a forced save
if (force || dirty)
{
int delay = 50; // Start with a delay of 50ms if a put fails.
try {
// Try 10 times with exponential back-off. The tenth time the
// delay will be about 25 seconds. We need to eventually give
// up because it is possible the Datastore API is totally hosed
// and we want the request to eventually terminate.
for (int attemptNum = 0; attemptNum < 10; attemptNum++) {
try {
synchronized (this) {
if (dirty || force ) {
for (SessionStore sessionStore : sessionStoresInWriteOrder) {
sessionStore.saveSession(key, sessionData);
}
dirty = false;
return;
}
}
} catch (SessionStore.Retryable retryable) {
// Don't break out of the loop
} catch (ApiProxy.ApiDeadlineExceededException e) {
// Don't break out of the loop
}
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
// Just try again prematurely
}
logger.warning("Timeout while saving session " + getId() + ".");
delay *= 2;
}
logger.log(Level.SEVERE, "Unable to save session " + getId() +
" - too many attempts");
} catch (DeadlineExceededException e) {
logger.log(Level.SEVERE, "Unable to save session " + getId() +
" - too many timeouts.", e);
}
}
}
@Override
public synchronized Object doGet(String name) {
return sessionData.getValueMap().get(name);
}
@Override
public synchronized Enumeration<String> doGetAttributeNames() {
return Collections.enumeration(sessionData.getValueMap().keySet());
}
@Override
public synchronized void setAttribute(String name, Object value) {
super.setAttribute(name, value);
dirty = true;
}
@Override
public Object doPutOrRemove(String name, Object value) {
return value == null ? sessionData.getValueMap().remove(name)
: sessionData.getValueMap().put(name, value);
}
@Override
public synchronized void removeAttribute(String name) {
super.removeAttribute(name);
dirty = true;
}
@Override
public void clearAttributes() {
while (sessionData.getValueMap() != null && sessionData.getValueMap().size() > 0) {
ArrayList<String> keys;
synchronized (this) {
keys = new ArrayList<String>(sessionData.getValueMap().keySet());
}
Iterator<String> iter = keys.iterator();
while (iter.hasNext()) {
String key = (String) iter.next();
Object value;
synchronized (this) {
value = doPutOrRemove(key, null);
}
unbindValue(key, value);
((SessionManager) getSessionManager()).doSessionAttributeListeners(this,
key, value, null);
}
}
if (sessionData.getValueMap() != null) {
sessionData.getValueMap().clear();
}
}
@Override
public Map<String, Object> getAttributeMap() {
return sessionData.getValueMap();
}
@Override
protected void timeout() throws IllegalStateException {
// TODO(user) only called by a session scavenger thread which appengine does not have.
// Sessions are only checked for expiry when a request comes in for them. If the
// SessionData is expired in the datastore, then getSession() returns null.
}
@Override
protected boolean access(long accessTime) {
// Optimize flushing of session data to persistent storage based on nearness to expiry time.
long expirationTime = sessionData.getExpirationTime();
long timeRemaining = expirationTime - accessTime;
if (dirty) {
} else if (timeRemaining < (getSessionExpirationInMilliseconds() * UPDATE_TIMESTAMP_RATIO)) {
dirty = true;
logger.fine("Session " + getId() + " accessed while near expiration, marking dirty.");
} else {
logger.fine("Session " + getId() + " accessed early, not marking dirty.");
}
sessionData.setExpirationTime(System.currentTimeMillis()
+ getSessionExpirationInMilliseconds());
// Handle session being invalid, update number of requests inside session.
return super.access(accessTime);
}
/**
* Check if the expiration time has passed
*
* @see org.eclipse.jetty.server.session.AbstractSession#checkExpiry(long)
*/
@Override
protected boolean checkExpiry(long time) {
long expirationTime = sessionData.getExpirationTime();
return (time >= expirationTime);
}
@Override
public synchronized void invalidate() throws IllegalStateException {
super.invalidate();
}
void deleteSession() {
for (SessionStore sessionStore : sessionStoresInWriteOrder) {
sessionStore.deleteSession(key);
}
}
@Override
public int getAttributes() {
return 0;
}
@Override
public synchronized Set<String> getNames() {
return new HashSet<String>(sessionData.getValueMap().keySet());
}
}
private final List<SessionStore> sessionStoresInWriteOrder;
private final List<SessionStore> sessionStoresInReadOrder;
/* used in tests, thus package-protected */
static String lastId() {
return lastId;
}
/**
* Constructs a SessionManager
*
* @param sessionStoresInWriteOrder The SessionStores in the order to which
* they should be written. When attempting to load a session, the read order
* is the opposite of the write order, so if the write order is A, B, C, when
* reading we will first consult C, and if the session is not found move on
* to B, and if not found then on to A.
*/
public SessionManager(List<SessionStore> sessionStoresInWriteOrder) {
super();
this.sessionStoresInWriteOrder = sessionStoresInWriteOrder;
// We'll always read in the opposite order we write, so create a copy
// of the stores in write order and then reverse it.
this.sessionStoresInReadOrder = new ArrayList<SessionStore>(sessionStoresInWriteOrder);
Collections.reverse(this.sessionStoresInReadOrder);
//NOTE: this breaks the standard jetty contract that a single server has a single SessionIdManager
//but there is 1 SessionManager per context.
_sessionIdManager = new SessionIdManager();
}
@Override
protected AppEngineSession newSession(HttpServletRequest request) {
// This will save the session persistently.
return new AppEngineSession(request);
}
@Override
public AppEngineSession getSession(String sessionId) {
SessionData data = loadSession(sessionId);
if (data != null) {
// Make access time same as create time.
long time = System.currentTimeMillis();
return new AppEngineSession(time, time, sessionId, data);
} else {
return null;
}
}
SessionData loadSession(String sessionId) {
String key = SESSION_PREFIX + sessionId;
SessionData data = null;
for (SessionStore sessionStore : sessionStoresInReadOrder) {
// Keep iterating until we find a store that has the session data we
// want.
try {
data = sessionStore.getSession(key);
if (data != null) {
break;
}
} catch (RuntimeException e) {
String msg = "Exception while loading session data";
logger.log(Level.WARNING, msg, e);
if (ApiProxy.getCurrentEnvironment() != null) {
ApiProxy.log(createWarningLogRecord(msg, e));
}
break;
}
}
if (data != null) {
if (System.currentTimeMillis() > data.getExpirationTime()) {
logger.fine("Session " + sessionId + " expired " +
((System.currentTimeMillis() - data.getExpirationTime()) / 1000) +
" seconds ago, ignoring.");
return null;
}
}
return data;
}
SessionData createSession(String sessionId) {
String key = SESSION_PREFIX + sessionId;
SessionData data = new SessionData();
data.setExpirationTime(System.currentTimeMillis() + getSessionExpirationInMilliseconds());
for (SessionStore sessionStore : sessionStoresInWriteOrder) {
try {
sessionStore.saveSession(key, data);
} catch (SessionStore.Retryable retryable) {
// rethrowing the cause to maintain backwards compatibility
throw retryable.getCause();
}
}
return data;
}
private long getSessionExpirationInMilliseconds() {
long seconds = getMaxInactiveInterval();
if (seconds < 0) {
return Integer.MAX_VALUE * 1000L;
} else {
return seconds * 1000;
}
}
private LogRecord createWarningLogRecord(String message, Throwable ex) {
StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);
printWriter.println(message);
if (ex != null) {
ex.printStackTrace(printWriter);
}
return new LogRecord(LogRecord.Level.warn,
System.currentTimeMillis() * 1000,
stringWriter.toString());
}
@Override
public void doStart() throws Exception {
// always use our special id manager one
_sessionIdManager.start();
addBean(_sessionIdManager,true);
super.doStart();
}
@Override
protected void addSession(AbstractSession session) {
// No list of sessions is kept in memory, so do nothing here.
}
@Override
protected boolean removeSession(String clusterId) {
AppEngineSession session = getSession(clusterId);
if (session != null) {
session.deleteSession();
return true;
}
return false;
}
@Override
protected void shutdownSessions() throws Exception {
// Called when the session manager is stopping. We don't need to do anything here.
}
/**
* Not used
*/
@Override
public void renewSessionId(String oldClusterId, String oldNodeId, String newClusterId, String newNodeId) {
//Not used. See instead AppEngineSession.renewId
}
/**
* Call any session id listeners registered.
* Usually done by renewSessionId() method, but that is not used in appengine.
* @param session
* @param oldId
*/
public void callSessionIdListeners (AbstractSession session, String oldId) {
HttpSessionEvent event = new HttpSessionEvent(session);
for (HttpSessionIdListener l:_sessionIdListeners)
{
l.sessionIdChanged(event, oldId);
}
}
}