/* * Copyright 2011 Martin Grotzke * * 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 de.javakaffee.web.msm; import static de.javakaffee.web.msm.Configurations.NODE_AVAILABILITY_CACHE_TTL_KEY; import static de.javakaffee.web.msm.Configurations.getSystemProperty; import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import de.javakaffee.web.msm.NodeAvailabilityCache.CacheLoader; /** * * @author <a href="mailto:martin.grotzke@javakaffee.de">Martin Grotzke</a> */ public class MemcachedNodesManager { /** * Provides queries to the storage. */ public static interface StorageClientCallback { /** * Must query the given key in memcached. */ @Nullable byte[] get(@Nonnull String key); } private static final Log LOG = LogFactory.getLog(MemcachedNodesManager.class); private static final String NODE_REGEX = "([\\w]+):([^:]+):([\\d]+)"; private static final Pattern NODE_PATTERN = Pattern.compile( NODE_REGEX ); private static final String NODES_REGEX = NODE_REGEX + "(?:(?:\\s+|,)" + NODE_REGEX + ")*"; private static final Pattern NODES_PATTERN = Pattern.compile( NODES_REGEX ); private static final String SINGLE_NODE_REGEX = "([^:]+):([\\d]+)"; private static final Pattern SINGLE_NODE_PATTERN = Pattern.compile( SINGLE_NODE_REGEX ); private static final String COUCHBASE_BUCKET_NODE_REGEX = "http://([^:]+):([\\d]+)/[\\w]+"; private static final Pattern COUCHBASE_BUCKET_NODE_PATTERN = Pattern.compile( COUCHBASE_BUCKET_NODE_REGEX ); private static final String COUCHBASE_BUCKET_NODES_REGEX = COUCHBASE_BUCKET_NODE_REGEX + "(?:(?:\\s+|,)" + COUCHBASE_BUCKET_NODE_REGEX + ")*"; private static final Pattern COUCHBASE_BUCKET_NODES_PATTERN = Pattern.compile( COUCHBASE_BUCKET_NODES_REGEX ); private static final int NODE_AVAILABILITY_CACHE_TTL = getSystemProperty(NODE_AVAILABILITY_CACHE_TTL_KEY, 1000); private final String _memcachedNodes; private final NodeIdList _primaryNodeIds; private final List<String> _failoverNodeIds; private final LinkedHashMap<InetSocketAddress, String> _address2Ids; private final boolean _encodeNodeIdInSessionId; private final StorageKeyFormat _storageKeyFormat; @Nullable private NodeIdService _nodeIdService; private SessionIdFormat _sessionIdFormat; /** * * @param memcachedNodes the original memcachedNodes configuration string * @param primaryNodeIds the list of primary node ids (memcachedNodes without failoverNodes). * @param failoverNodeIds the configured failover node ids. * @param address2Ids a mapping of inet addresses from the memcachedNodes configuration to their node ids. * @param storageKeyFormat the storage key format * @param storageClientCallback a callback to memcached, can only be null if the memcachedNodes config * contains a single node without node id. */ public MemcachedNodesManager(final String memcachedNodes, @Nonnull final NodeIdList primaryNodeIds, @Nonnull final List<String> failoverNodeIds, @Nonnull final LinkedHashMap<InetSocketAddress, String> address2Ids, @Nullable final StorageKeyFormat storageKeyFormat, @Nullable final StorageClientCallback storageClientCallback) { _memcachedNodes = memcachedNodes; _primaryNodeIds = primaryNodeIds; _failoverNodeIds = failoverNodeIds; _address2Ids = address2Ids; _storageKeyFormat = storageKeyFormat; _encodeNodeIdInSessionId = !((getCountNodes() <= 1 || isCouchbaseConfig(memcachedNodes)) && _primaryNodeIds.isEmpty()); if (_encodeNodeIdInSessionId) { if (storageClientCallback == null) { throw new IllegalArgumentException("The MemcachedClientCallback must not be null."); } _sessionIdFormat = new SessionIdFormat(storageKeyFormat); _nodeIdService = new NodeIdService( createNodeAvailabilityCache( getCountNodes(), NODE_AVAILABILITY_CACHE_TTL, storageClientCallback), primaryNodeIds, failoverNodeIds ); } else { _sessionIdFormat = new SessionIdFormat(storageKeyFormat) { @Override public boolean isValid(final String sessionId) { return sessionId != null; } @Override public String createBackupKey(final String origKey) { throw new UnsupportedOperationException("Not supported for single node configuration without node id."); } @Override public String createSessionId(final String sessionId, final String memcachedId) { return sessionId; } @Override public String extractMemcachedId(final String sessionId) { throw new UnsupportedOperationException("Not supported for single node configuration without node id."); } }; _nodeIdService = null; } } private boolean isCouchbaseConfig(final String memcachedNodes) { return memcachedNodes.startsWith("http://"); } protected NodeAvailabilityCache<String> createNodeAvailabilityCache( final int size, final long ttlInMillis, @Nonnull final StorageClientCallback storageClientCallback) { return new NodeAvailabilityCache<String>( size, ttlInMillis, new CacheLoader<String>() { @Override public boolean isNodeAvailable( final String key ) { try { storageClientCallback.get(_sessionIdFormat.createSessionId( "ping", key ) ); return true; } catch ( final Exception e ) { return false; } } } ); } /** * Parses the given memcachedNodes definition and returns of {@link MemcachedNodesManager}. * Supported memcachedNodes formats: * <ul> * <li><code><hostOrIPAddress>:<port></code> - e.g. <code>localhost:11211</code></li> * <li><code><http://hostOrIPAddress>:<port>/<path></code> - e.g. <code>http://localhost:8091/pools</code></li> * <li><code><nodeId>:<hostOrIPAddress>:<port></code> - e.g. <code>n1:localhost:11211</code></li> * <li><code><nodeId>:<hostOrIPAddress>:<port>([ ,]<nodeId>:<hostOrIPAddress>:<port>)+</code> - e.g. * <ul> * <li><code>n1:localhost:11211,n2:localhost:11212</code></li> * <li><code>n1:localhost:11211 n2:localhost:11212</code></li> * </ul> * </li> * </ul> * @param memcachedNodes * @param failoverNodes TODO * @param storageKeyPrefix TODO * @param storageClientCallback TODO * @return */ @Nonnull public static MemcachedNodesManager createFor(final String memcachedNodes, final String failoverNodes, final StorageKeyFormat storageKeyFormat, final StorageClientCallback storageClientCallback) { if ( memcachedNodes == null || memcachedNodes.trim().isEmpty() ) { throw new IllegalArgumentException("null or empty memcachedNodes not allowed."); } // Support a Redis URL in the form "redis://hostname:port" or "rediss://" (for SSL connections) like the client "Lettuce" does if (memcachedNodes.startsWith("redis://") || memcachedNodes.startsWith("rediss://")) { // Redis configuration return new MemcachedNodesManager(memcachedNodes, new NodeIdList(), new ArrayList<String>(), new LinkedHashMap<InetSocketAddress, String>(), storageKeyFormat, storageClientCallback); } if ( !NODES_PATTERN.matcher( memcachedNodes ).matches() && !SINGLE_NODE_PATTERN.matcher(memcachedNodes).matches() && !COUCHBASE_BUCKET_NODES_PATTERN.matcher(memcachedNodes).matches()) { throw new IllegalArgumentException( "Configured memcachedNodes attribute has wrong format, must match " + NODES_REGEX ); } final Matcher singleNodeMatcher = SINGLE_NODE_PATTERN.matcher(memcachedNodes); // we have a linked hashmap to have insertion order for addresses final LinkedHashMap<InetSocketAddress, String> address2Ids = new LinkedHashMap<InetSocketAddress, String>(1); /** * If mutliple nodes are configured */ if (singleNodeMatcher.matches()) { // for single address2Ids.put(getSingleShortNodeDefinition(singleNodeMatcher), null); } else if (COUCHBASE_BUCKET_NODES_PATTERN.matcher(memcachedNodes).matches()) { // for couchbase final Matcher matcher = COUCHBASE_BUCKET_NODE_PATTERN.matcher(memcachedNodes); while (matcher.find()) { final String hostname = matcher.group( 1 ); final int port = Integer.parseInt( matcher.group( 2 ) ); address2Ids.put(new InetSocketAddress( hostname, port ), null); } if (address2Ids.isEmpty()) { throw new IllegalArgumentException("All nodes are also configured as failover nodes," + " this is a configuration failure. In this case, you probably want to leave out the failoverNodes."); } } else { // If mutliple nodes are configured final Matcher matcher = NODE_PATTERN.matcher( memcachedNodes); while (matcher.find()) { final Pair<String, InetSocketAddress> nodeInfo = getRegularNodeDefinition(matcher); address2Ids.put(nodeInfo.getSecond(), nodeInfo.getFirst()); } if (address2Ids.isEmpty()) { throw new IllegalArgumentException("All nodes are also configured as failover nodes," + " this is a configuration failure. In this case, you probably want to leave out the failoverNodes."); } } final List<String> failoverNodeIds = initFailoverNodes(failoverNodes, address2Ids.values()); // validate that for a single node there's no failover node specified as this does not make sense. if(address2Ids.size() == 1 && failoverNodeIds.size() >= 1) { throw new IllegalArgumentException("For a single memcached node there should/must no failoverNodes be specified."); } final NodeIdList primaryNodeIds = new NodeIdList(); for(final Map.Entry<InetSocketAddress, String> address2Id : address2Ids.entrySet()) { final String nodeId = address2Id.getValue(); if (nodeId != null && !failoverNodeIds.contains(nodeId) ) { primaryNodeIds.add(nodeId); } } return new MemcachedNodesManager(memcachedNodes, primaryNodeIds, failoverNodeIds, address2Ids, storageKeyFormat, storageClientCallback); } private static InetSocketAddress getSingleShortNodeDefinition(final Matcher singleNodeMatcher) { final String hostname = singleNodeMatcher.group(1); final int port = Integer.parseInt(singleNodeMatcher.group(2)); return new InetSocketAddress(hostname, port); } private static Pair<String, InetSocketAddress> getRegularNodeDefinition(final Matcher matcher) { final String nodeId = matcher.group( 1 ); final String hostname = matcher.group( 2 ); final int port = Integer.parseInt( matcher.group( 3 ) ); final InetSocketAddress address = new InetSocketAddress( hostname, port ); return Pair.of(nodeId, address); } private static List<String> initFailoverNodes(final String failoverNodes, final Collection<String> allNodeIds) { final List<String> failoverNodeIds = new ArrayList<String>(); if ( failoverNodes != null && failoverNodes.trim().length() != 0 ) { final String[] failoverNodesArray = failoverNodes.split( " |," ); for ( final String failoverNodeId : failoverNodesArray ) { final String failoverNodeIdTrimmed = failoverNodeId.trim(); if ( !allNodeIds.contains( failoverNodeIdTrimmed ) ) { throw new IllegalArgumentException( "Invalid failover node id " + failoverNodeIdTrimmed + ": " + "not existing in memcachedNodes '" + allNodeIds + "'." ); } failoverNodeIds.add( failoverNodeIdTrimmed ); } } return failoverNodeIds; } /** * Provides the original memcachedNodes configuration string. */ public String getMemcachedNodes() { return _memcachedNodes; } /** * Returns the number of memcached nodes. */ public int getCountNodes() { return _address2Ids.size(); } /** * Returns the primary node ids, which are the memcachedNodes that are not specified in failoverNodes. */ @Nonnull public NodeIdList getPrimaryNodeIds() { return _primaryNodeIds; } /** * Returns the failover node ids as specified by failoverNodes in the config. */ @Nonnull public List<String> getFailoverNodeIds() { return _failoverNodeIds; } /** * Specifies if the memcached node id shall be encoded in the sessionId. This is only false * for a single memcachedNode definition without a nodeId (e.g. <code>localhost:11211</code>) * or for couchbase REST URIs (one or more of e.g. http://10.10.0.1:8091/pools). */ public boolean isEncodeNodeIdInSessionId() { return _encodeNodeIdInSessionId; } /** * Return the nodeId for the given socket address. Returns <code>null</code> * if the socket address is not known. * @throws IllegalArgumentException thrown when the socketAddress is <code>null</code> or not registered with this {@link MemcachedNodesManager}. */ @Nonnull public String getNodeId(final InetSocketAddress socketAddress) throws IllegalArgumentException { if ( socketAddress == null ) { throw new IllegalArgumentException("SocketAddress must not be null."); } final String result = _address2Ids.get( socketAddress ); if ( result == null ) { throw new IllegalArgumentException("SocketAddress " + socketAddress + " not known (registered addresses: " + _address2Ids.keySet() + ")."); } return result; } /** * Get the next node id for the given one, based on the primary node ids (memcachedNodes without failoverNodes). * For the last node id the first one is returned. * If this list contains only a single node, conceptionally there's no next node * so that <code>null</code> is returned. * @return the next node id or <code>null</code> if there's no next node id. * @throws IllegalArgumentException thrown if the given nodeId is not part of this list. */ @CheckForNull public String getNextPrimaryNodeId(final String nodeId) { return _primaryNodeIds.getNextNodeId(nodeId); } /** * Get the next available node id for the given one, based on the primary node ids * (memcachedNodes without failoverNodes). For the last node id the first one is returned. * If this list contains only a single node, conceptionally there's no next node * so that <code>null</code> is returned. * @return the next available node id or <code>null</code> if there's no next available node id. * @see #getNextPrimaryNodeId(String) * @see #isNodeAvailable(String) */ public String getNextAvailableNodeId(final String nodeId) { String result = nodeId; do { result = _primaryNodeIds.getNextNodeId(result); if(result != null && result.equals(nodeId)) { result = null; } } while(result != null && !isNodeAvailable(result)); return result; } /** * Provides access to the {@link SessionIdFormat} handling sessionIds for this memcached Nodes configuration. */ @Nonnull public SessionIdFormat getSessionIdFormat() { return _sessionIdFormat; } /** * Provides the {@link StorageKeyFormat} to create the storage key. */ @Nonnull public StorageKeyFormat getStorageKeyFormat() { return _storageKeyFormat; } /** * Must return all known memcached addresses. */ @Nonnull public List<InetSocketAddress> getAllMemcachedAddresses() { return new ArrayList<InetSocketAddress>( _address2Ids.keySet() ); } /** * Creates a new sessionId based on the given one, usually by appending a randomly selected memcached node id. * If the memcachedNodes were configured using a single node without nodeId, the sessionId is returned unchanged. */ @Nonnull public String createSessionId( @Nonnull final String sessionId ) { return isEncodeNodeIdInSessionId() ? _sessionIdFormat.createSessionId(sessionId, _nodeIdService.getMemcachedNodeId() ) : sessionId; } /** * Mark the given nodeId as available as specified. * @param nodeId the nodeId to update * @param available specifies if the node was abailable or not */ public void setNodeAvailable(@Nullable final String nodeId, final boolean available) { if ( _nodeIdService != null ) { _nodeIdService.setNodeAvailable(nodeId, available); } } /** * Determines, if the given nodeId is available. * @param nodeId the node to check, not <code>null</code>. * @return <code>true</code>, if the node is marked as available */ public boolean isNodeAvailable(final String nodeId) { return _nodeIdService.isNodeAvailable(nodeId); } /** * Can be used to determine if the given sessionId can be used to interact with memcached. * @see #canHitMemcached(String) */ public boolean isValidForMemcached(final String sessionId) { if ( isEncodeNodeIdInSessionId() ) { final String nodeId = _sessionIdFormat.extractMemcachedId( sessionId ); if ( nodeId == null ) { LOG.debug( "The sessionId does not contain a nodeId so that the memcached node could not be identified." ); return false; } } return true; } /** * Can be used to determine if the given sessionId can be used to interact with memcached. * This also checks if the related memcached is available. * @see #isValidForMemcached(String) */ public boolean canHitMemcached(final String sessionId) { if ( isEncodeNodeIdInSessionId() ) { final String nodeId = _sessionIdFormat.extractMemcachedId( sessionId ); if ( nodeId == null ) { LOG.debug( "The sessionId does not contain a nodeId so that the memcached node could not be identified." ); return false; } if ( !_nodeIdService.isNodeAvailable( nodeId ) ) { LOG.debug( "The node "+ nodeId +" is not available, therefore " + sessionId + " cannot be loaded from this memcached." ); return false; } } return true; } public void onLoadFromMemcachedSuccess(final String sessionId) { setNodeAvailableForSessionId(sessionId, true); } public void onLoadFromMemcachedFailure(final String sessionId) { setNodeAvailableForSessionId(sessionId, false); } /** * Mark the memcached node encoded in the given sessionId as available or not. If nodeIds shall * not be encoded in the sessionId or if the given sessionId does not contain a nodeId no * action will be taken. * * @param sessionId the sessionId that may contain a node id. * @param available specifies if the possibly referenced node is available or not. * * @return the extracted nodeId or <code>null</code>. * * @see #isEncodeNodeIdInSessionId() */ public String setNodeAvailableForSessionId(final String sessionId, final boolean available) { if ( _nodeIdService != null && isEncodeNodeIdInSessionId() ) { final String nodeId = _sessionIdFormat.extractMemcachedId(sessionId); if ( nodeId != null ) { _nodeIdService.setNodeAvailable(nodeId, available); return nodeId; } else { LOG.warn("Got sessionId without nodeId: " + sessionId); } } return null; } /** * Returns a new session id if node information shall be encoded in the session id * and the encoded nodeId given one is <code>null</code> or not available. * @param sessionId the session id that is checked. * @return a new session id or <code>null</code>. */ public String getNewSessionIdIfNodeFromSessionIdUnavailable( @Nonnull final String sessionId ) { if ( isEncodeNodeIdInSessionId() ) { final String nodeId = _sessionIdFormat.extractMemcachedId( sessionId ); final String newNodeId = _nodeIdService.getNewNodeIdIfUnavailable( nodeId ); if ( newNodeId != null ) { return _sessionIdFormat.createNewSessionId( sessionId, newNodeId); } } return null; } /** * Changes the sessionId by setting the given jvmRoute and replacing the memcachedNodeId if it's currently * set to a failoverNodeId. * @param sessionId the current session id * @param jvmRoute the new jvmRoute to set. * @return the session id with maybe new jvmRoute and/or new memcachedId. */ public String changeSessionIdForTomcatFailover( @Nonnull final String sessionId, final String jvmRoute ) { final String newSessionId = jvmRoute != null && !jvmRoute.trim().isEmpty() ? _sessionIdFormat.changeJvmRoute( sessionId, jvmRoute ) : _sessionIdFormat.stripJvmRoute(sessionId); if ( isEncodeNodeIdInSessionId() ) { final String nodeId = _sessionIdFormat.extractMemcachedId( newSessionId ); if(_failoverNodeIds != null && _failoverNodeIds.contains(nodeId)) { final String newNodeId = _nodeIdService.getAvailableNodeId( nodeId ); if ( newNodeId != null ) { return _sessionIdFormat.createNewSessionId( newSessionId, newNodeId); } } } return newSessionId; } /** * Determines, if the current memcachedNodes configuration is a couchbase bucket configuration * (like e.g. http://10.10.0.1:8091/pools). */ public boolean isCouchbaseBucketConfig() { return COUCHBASE_BUCKET_NODES_PATTERN.matcher(_memcachedNodes).matches(); } /** * Determines, if the current memcachedNodes configuration is a Redis configuration * (like e.g. redis://example.com or rediss://example.com). */ public boolean isRedisConfig() { return _memcachedNodes.startsWith("redis://") || _memcachedNodes.startsWith("rediss://"); } /** * Returns a list of couchbase REST interface uris if the current configuration is * a couchbase bucket configuration. * @see #isCouchbaseBucketConfig() */ public List<URI> getCouchbaseBucketURIs() { if(!isCouchbaseBucketConfig()) throw new IllegalStateException("This is not a couchbase bucket configuration."); final List<URI> result = new ArrayList<URI>(_address2Ids.size()); final Matcher matcher = COUCHBASE_BUCKET_NODE_PATTERN.matcher(_memcachedNodes); while (matcher.find()) { try { result.add(new URI(matcher.group())); } catch (final URISyntaxException e) { throw new RuntimeException(e); } } return result; } }