package com.amadeus.session.repository.redis; import static com.amadeus.session.repository.redis.SafeEncoder.encode; import static com.codahale.metrics.MetricRegistry.name; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.amadeus.session.SerializerDeserializer; import com.amadeus.session.SessionConfiguration; import com.amadeus.session.SessionData; import com.amadeus.session.SessionManager; import com.amadeus.session.SessionRepository; import com.codahale.metrics.Meter; import com.codahale.metrics.Metric; import com.codahale.metrics.MetricFilter; import com.codahale.metrics.MetricRegistry; /** * Main class for implementing Redis repository logic. */ public class RedisSessionRepository implements SessionRepository { private static final Logger logger = LoggerFactory.getLogger(RedisSessionRepository.class); /** * The default prefix for each key and channel in Redis used by Session management */ static final String DEFAULT_SESSION_PREFIX = "com.amadeus.session:"; /** * Meta attribute for timestamp (Unix time) of last access to session. */ static final byte[] LAST_ACCESSED = encode("#:lastAccessed"); /** * Meta attribute for maximum inactive interval of the session (in seconds). */ static final byte[] MAX_INACTIVE_INTERVAL = encode("#:maxInactiveInterval"); /** * Meta attribute for timestamp (Unix time) of the session creation. */ static final byte[] CREATION_TIME = encode("#:creationTime"); /** * Meta attribute that contains mark if the session is invalid (being deleted or marked as invalid via API). */ static final byte[] INVALID_SESSION = encode("#:invalidSession"); /** * Meta attribute for the node owning the session. */ static final byte[] OWNER_NODE = encode("#:owner"); /** * Representation of true value */ static final byte[] BYTES_TRUE = SafeEncoder.encode(String.valueOf(1)); /** * All attributes starting with #: are internal (meta-atrributes). */ private static final byte[] INTERNAL_PREFIX = new byte[] { '#', ':' }; private static final int CREATION_TIME_INDEX = 2; private static final int INVALID_SESSION_INDEX = 3; private static final int OWNER_NODE_INDEX = 4; private static final RedisFacade.ResponseFacade<String> OK_RESULT = new RedisFacade.ResponseFacade<String>() { @Override public String get() { return "OK"; } }; /** * Number of bits in byte. Used to allocate byte buffers to store Long and Integer values. */ private static final int BITS_IN_BYTE = 8; private final String owner; private final byte[] ownerByteArray; private final String keyPrefix; private final byte[] keyPrefixByteArray; private byte[] redirectionsChannel; private final RedisFacade redis; final RedisExpirationStrategy expirationManager; private SessionManager sessionManager; private Meter failoverMetrics; private boolean sticky; private final String namespace; public RedisSessionRepository(RedisFacade redis, String namespace, String owner, ExpirationStrategy strategy, boolean sticky) { this.redis = redis; this.owner = owner; this.namespace = namespace; this.ownerByteArray = encode(owner); String keyPrefixWithoutClusterGroup = DEFAULT_SESSION_PREFIX + ":" + namespace + ":"; keyPrefix = keyPrefixWithoutClusterGroup + "{"; keyPrefixByteArray = encode(keyPrefix); redirectionsChannel = encode(keyPrefixWithoutClusterGroup + "redirection"); this.sticky = sticky; if (strategy == ExpirationStrategy.ZRANGE) { logger.info("Using ZRANGE (SortedSet) expiration managment"); expirationManager = new SortedSetSessionExpirationManagement(redis, this, namespace, sticky, owner); } else { logger.info("Using notification expiration managment"); expirationManager = new NotificationExpirationManagement(redis, this, namespace, owner, keyPrefixWithoutClusterGroup, sticky); } } /** * This method starts a separate thread that listens to key expirations events. * * @param sessionManager */ @Override public void setSessionManager(final SessionManager sessionManager) { this.sessionManager = sessionManager; MetricRegistry metrics = sessionManager.getMetrics(); if (metrics != null) { // Cleanup old metrics related to this namespace metrics.removeMatching(new MetricFilter() { @Override public boolean matches(String name, Metric metric) { return name.startsWith(name(RedisConfiguration.METRIC_PREFIX, "redis")); } }); if (sticky) { failoverMetrics = metrics.meter(name(RedisConfiguration.METRIC_PREFIX, namespace, "redis", "failover")); } redis.startMonitoring(metrics); } expirationManager.startExpiredSessionsTask(sessionManager); } /** * This method retrieves session data from repository. The data retrieved from repository contains meta attributes * such as: last accessed time, creation time, maximum inactive interval, flag if session is invalid (session becomes * invalid when it is deleted or marked invalid), and if session stickiness is active, previous owner node id. All * meta attributes start with following characters <code>#:</code> * * @param id * session id */ @Override public SessionData getSessionData(String id) { byte[] key = sessionKey(id); // If sticky session, retrieve last owner also List<byte[]> values = sticky ? redis.hmget(key, LAST_ACCESSED, MAX_INACTIVE_INTERVAL, CREATION_TIME, INVALID_SESSION, OWNER_NODE) : redis.hmget(key, LAST_ACCESSED, MAX_INACTIVE_INTERVAL, CREATION_TIME, INVALID_SESSION); if (!checkConsistent(id, values)) { return null; } long lastAccessed = longFrom(values.get(0)); long creationTime = longFrom(values.get(CREATION_TIME_INDEX)); String previousOwner = null; if (sticky) { // For sticky sessions, we need to parse owner node and // check if it is this one. byte[] prevOwnerBuffer = values.get(OWNER_NODE_INDEX); if (prevOwnerBuffer != null) { previousOwner = encode(prevOwnerBuffer); if (!previousOwner.equals(owner)) { // Notify that session fail-over occurred logger.info("Retrieved session {}, last node {} to this node {}", id, previousOwner, owner); if (failoverMetrics != null) { failoverMetrics.mark(); } } } } int maxInactiveInterval = SessionConfiguration.DEFAULT_SESSION_TIMEOUT_VALUE_NUM; byte[] maxInactiveIntervalByte = values.get(1); if (maxInactiveIntervalByte != null && maxInactiveIntervalByte.length != 0) { maxInactiveInterval = intFrom(maxInactiveIntervalByte); } return new SessionData(id, lastAccessed, maxInactiveInterval, creationTime, previousOwner); } /** * Verifies if values retrieved from redis are consistent. Basically just sanity checks. * * @param sessionId * @param values * @return <code>true</code> if session data is consistent. */ private boolean checkConsistent(String sessionId, List<byte[]> values) { byte[] invalidSessionFlag = values.get(INVALID_SESSION_INDEX); // If we have invalid session flag, then session is (clearly) not valid if (invalidSessionFlag != null && invalidSessionFlag.length == 1 && invalidSessionFlag[0] == 1) { return false; } if (values.get(0) == null || values.get(1) == null) { if (values.get(0) != null || values.get(1) != null) { logger.warn( "Session in redis repository is not consistent for sessionId: '{}' " + "One of last accessed (index 0 in array), max inactive interval (index 1 in array) was null: {}", sessionId, values); } return false; } return true; } /** * Get integer from byte array * * @param b * @return */ private static int intFrom(byte[] b) { return ByteBuffer.wrap(b).getInt(); } /** * Get long from byte array * * @param b * @return */ private static long longFrom(byte[] b) { return ByteBuffer.wrap(b).getLong(); } /** * Adds long value to redis attribute map. * * @param attributes * @param attr * @param value */ private static void addLong(Map<byte[], byte[]> attributes, byte[] attr, long value) { // In JDK 1.8 we can use Long.BYTES ByteBuffer b = ByteBuffer.allocate(Long.SIZE / BITS_IN_BYTE); b.putLong(value); attributes.put(attr, b.array()); } /** * Adds int value to redis attribute map. * * @param attributes * @param attr * @param value */ private static void addInt(Map<byte[], byte[]> attributes, byte[] attr, int value) { ByteBuffer b = ByteBuffer.allocate(Integer.SIZE / BITS_IN_BYTE); b.putInt(value); attributes.put(attr, b.array()); } /** * This class implements transaction that is executed at session commit time. The transaction will store * added/modified session attribute keys using single HMSET redis command and it will also delete removed session * attribute keys using HDEL command. It uses underlying {@link RedisFacade} support for transactions on the session * key (redis MULTI command), and executes those those commands in atomic way. The meta-attribute for transactions are * also updated. */ class RedisSessionTransaction implements SessionRepository.CommitTransaction, RedisFacade.TransactionRunner<String> { private Map<byte[], byte[]> attributes = new HashMap<>(); private List<byte[]> toRemove = new ArrayList<>(); private byte[] key; private SessionData session; RedisSessionTransaction(SessionData session) { key = sessionKey(session.getId()); this.session = session; } @Override public void addAttribute(String attribute, Object value) { attributes.put(encode(attribute), serializerDeserializer().serialize(value)); } @Override public void removeAttribute(String attribute) { toRemove.add(encode(attribute)); } /** * During commit, we add meta/attributes. See {@link RedisSessionRepository#getSessionData(String)}. for list of * meta attributes. */ @Override public void commit() { if (session.isNew()) { addLong(attributes, CREATION_TIME, session.getCreationTime()); } int maxInactiveInterval = session.getMaxInactiveInterval(); addInt(attributes, MAX_INACTIVE_INTERVAL, maxInactiveInterval); addLong(attributes, LAST_ACCESSED, session.getLastAccessedTime()); if (sessionManager.getConfiguration().isSticky()) { attributes.put(OWNER_NODE, ownerByteArray); } getRedis().transaction(key, this); expirationManager.sessionTouched(session); } @Override public RedisFacade.ResponseFacade<String> run(RedisFacade.TransactionFacade transaction) { if (!toRemove.isEmpty()) { byte[][] arr = toRemove.toArray(new byte[0][]); transaction.hdel(key, arr); } if (!attributes.isEmpty()) { transaction.hmset(key, attributes); } return OK_RESULT; } @Override public boolean isSetAllAttributes() { // As we use hash, we don't need to update all attributes on redis return false; } @Override public boolean isDistributing() { // Redis sessions are surely distributed return true; } } @Override public CommitTransaction startCommit(SessionData session) { return new RedisSessionTransaction(session); } @Override public void remove(SessionData session) { redis.del(sessionKey(session.getId())); expirationManager.sessionDeleted(session); } /** * Returns session key used to index session in redis. * * @param sessionId * @return key as byte array */ public byte[] sessionKey(String sessionId) { return encode(new StringBuilder(keyPrefix.length() + sessionId.length() + 1).append(keyPrefix).append(sessionId) .append('}').toString()); } /** * Returns session key used to index session in Redis * * @param session * session data * @return key as byte array */ private byte[] sessionKey(SessionData session) { return sessionKey(session.getId()); } @Override public Object getSessionAttribute(SessionData session, String attribute) { List<byte[]> values = redis.hmget(sessionKey(session), encode(attribute)); return serializerDeserializer().deserialize(values.get(0)); } /** * Checks if attribute has internal prefix. See {@link #INTERNAL_PREFIX}. * * @param buf * buffer containing attribute * @return true if attribute is an internal attribute. */ static boolean hasInternalPrefix(byte[] buf) { if (buf != null && buf.length > INTERNAL_PREFIX.length) { for (int i = 0; i < INTERNAL_PREFIX.length; i++) { if (INTERNAL_PREFIX[i] != buf[i]) { return false; } } return true; } return false; } /** * Builds session key from session id presented as byte array * * @param session * @return session key as byte array */ byte[] getSessionKey(byte[] session) { int prefixLength = keyPrefixByteArray.length; byte[] copy = Arrays.copyOf(keyPrefixByteArray, prefixLength + session.length + 1); for (int i = 0; i < session.length; i++) { copy[prefixLength + i] = session[i]; } copy[prefixLength + session.length] = '}'; return copy; } @Override public boolean prepareRemove(SessionData session) { Long result = redis.hsetnx(sessionKey(session.getId()), INVALID_SESSION, BYTES_TRUE); return result.intValue() == 1; } /** * Retrieves all attribute keys associated with session. No * * @param session */ @Override public Set<String> getAllKeys(SessionData session) { Set<String> keys = new HashSet<>(); for (byte[] key : redis.hkeys(sessionKey(session))) { if (!hasInternalPrefix(key)) { keys.add(encode(key)); } } return Collections.unmodifiableSet(keys); } /** * The method stores session metadata in redis and marks session as accessed (resets session expire instant). */ @Override public void storeSessionData(SessionData sessionData) { Map<byte[], byte[]> attributes = new HashMap<>(); addInt(attributes, MAX_INACTIVE_INTERVAL, sessionData.getMaxInactiveInterval()); addLong(attributes, LAST_ACCESSED, sessionData.getLastAccessedTime()); addLong(attributes, CREATION_TIME, sessionData.getCreationTime()); if (sessionManager.getConfiguration().isSticky()) { attributes.put(OWNER_NODE, ownerByteArray); } redis.hmset(sessionKey(sessionData.getId()), attributes); expirationManager.sessionTouched(sessionData); } @Override public void requestFinished() { redis.requestFinished(); } @Override public void setSessionAttribute(SessionData session, String name, Object value) { redis.hset(sessionKey(session), encode(name), serializerDeserializer().serialize(value)); } private SerializerDeserializer serializerDeserializer() { return sessionManager.getSerializerDeserializer(); } @Override public void removeSessionAttribute(SessionData session, String name) { redis.hdel(sessionKey(session), encode(name)); } @Override public boolean cleanSessionsOnShutdown() { return false; } @Override public Collection<String> getOwnedSessionIds() { throw new UnsupportedOperationException("Redis repository doesn't support retrieval of session ids owned by node."); } @Override public void close() { redis.close(); expirationManager.close(); } @Override public void reset() { try { redis.close(); } catch (Exception e) { logger.warn("redis reset generated problems:", e); } expirationManager.reset(); } RedisFacade getRedis() { return redis; } /** * This method extracts session id from session key used in Redis. Session keys is located between braces ({}). * * @param body * string containing session key * @return session id */ static String extractSessionId(String body) { int beginIndex = body.lastIndexOf(':') + 1; String sessionId = body.substring(beginIndex); int braceOpening = sessionId.indexOf('{'); if (braceOpening >= 0) { int braceClosing = sessionId.indexOf('}', braceOpening + 1); if (braceClosing > braceOpening) { int idLen = sessionId.length(); StringBuilder sb = new StringBuilder(idLen - 2); // NOSONAR if (braceOpening > 0) { sb.append(sessionId, 0, braceOpening); } sb.append(sessionId, braceOpening + 1, braceClosing).append(sessionId, braceClosing + 1, idLen); sessionId = sb.toString(); } } return sessionId; } /** * Changes session id. This renames key in redis and publishes the redis event if other nodes need to be notified. * * @param sessionData * content of the session */ @Override public void sessionIdChange(SessionData sessionData) { redis.rename(sessionKey(sessionData.getOldSessionId()), sessionKey(sessionData.getId())); redis.publish(redirectionsChannel, encode(sessionData.getOldSessionId() + ':' + sessionData.getId())); expirationManager.sessionIdChange(sessionData); } @Override public boolean isConnected() { try { redis.info("server"); return true; } catch (Exception e) { return false; } } }