// ================================================================================================= // Copyright 2011 Twitter, Inc. // ------------------------------------------------------------------------------------------------- // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this work except in compliance with the License. // You may obtain a copy of the License in the LICENSE file, or 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.twitter.common.zookeeper; import java.io.IOException; import java.net.InetSocketAddress; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.logging.Logger; import javax.annotation.Nullable; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.KeeperException.SessionExpiredException; import org.apache.zookeeper.WatchedEvent; import org.apache.zookeeper.Watcher; import org.apache.zookeeper.Watcher.Event.EventType; import org.apache.zookeeper.Watcher.Event.KeeperState; import org.apache.zookeeper.ZooKeeper; import com.twitter.common.base.Command; import com.twitter.common.base.MorePreconditions; import com.twitter.common.net.InetSocketAddressHelper; import com.twitter.common.quantity.Amount; import com.twitter.common.quantity.Time; /** * Manages a connection to a ZooKeeper cluster. * * @author John Sirois */ public class ZooKeeperClient { /** * Indicates an error connecting to a zookeeper cluster. * * @author John Sirois */ public class ZooKeeperConnectionException extends Exception { public ZooKeeperConnectionException(String message, Throwable cause) { super(message, cause); } } /** * Encapsulates a user's credentials and has the ability to authenticate them through a * {@link ZooKeeper} client. */ public interface Credentials { /** * A set of {@code Credentials} that performs no authentication. */ Credentials NONE = new Credentials() { @Override public void authenticate(ZooKeeper zooKeeper) { // noop } @Override public String scheme() { return null; } @Override public byte[] authToken() { return null; } }; /** * Authenticates these credentials against the given {@code ZooKeeper} client. * * @param zooKeeper the client to authenticate */ void authenticate(ZooKeeper zooKeeper); /** * Returns the authentication scheme these credentials are for. * * @return the scheme these credentials are for or {@code null} if no authentication is * intended. */ @Nullable String scheme(); /** * Returns the authentication token. * * @return the authentication token or {@code null} if no authentication is intended. */ @Nullable byte[] authToken(); } /** * Creates a set of credentials for the zoo keeper digest authentication mechanism. * * @param username the username to authenticate with * @param password the password to authenticate with * @return a set of credentials that can be used to authenticate the zoo keeper client */ public static Credentials digestCredentials(String username, String password) { MorePreconditions.checkNotBlank(username); Preconditions.checkNotNull(password); // TODO(John Sirois): DigestAuthenticationProvider is broken - uses platform default charset // (on server) and so we just have to hope here that clients are deployed in compatible jvms. // Consider writing and installing a version of DigestAuthenticationProvider that controls its // Charset explicitly. return credentials("digest", (username + ":" + password).getBytes()); } /** * Creates a set of credentials for the given authentication {@code scheme}. * * @param scheme the scheme to authenticate with * @param authToken the authentication token * @return a set of credentials that can be used to authenticate the zoo keeper client */ public static Credentials credentials(final String scheme, final byte[] authToken) { MorePreconditions.checkNotBlank(scheme); Preconditions.checkNotNull(authToken); return new Credentials() { @Override public void authenticate(ZooKeeper zooKeeper) { zooKeeper.addAuthInfo(scheme, authToken); } @Override public String scheme() { return scheme; } @Override public byte[] authToken() { return authToken; } }; } private final class SessionState { private final long sessionId; private final byte[] sessionPasswd; private SessionState(long sessionId, byte[] sessionPasswd) { this.sessionId = sessionId; this.sessionPasswd = sessionPasswd; } } private static final Logger LOG = Logger.getLogger(ZooKeeperClient.class.getName()); private static final Amount<Long,Time> WAIT_FOREVER = Amount.of(0L, Time.MILLISECONDS); private final int sessionTimeoutMs; private final Credentials credentials; private final String zooKeeperServers; // GuardedBy "this", but still volatile for tests, where we want to be able to see writes // made from within long synchronized blocks. private volatile ZooKeeper zooKeeper; private SessionState sessionState; private final Set<Watcher> watchers = Collections.synchronizedSet(new HashSet<Watcher>()); private static Iterable<InetSocketAddress> combine(InetSocketAddress address, InetSocketAddress... addresses) { return ImmutableSet.<InetSocketAddress>builder().add(address).add(addresses).build(); } /** * Creates an unconnected client that will lazily attempt to connect on the first call to * {@link #get()}. * * @param sessionTimeout the ZK session timeout * @param zooKeeperServer the first, required ZK server * @param zooKeeperServers any additional servers forming the ZK cluster */ public ZooKeeperClient(Amount<Integer, Time> sessionTimeout, InetSocketAddress zooKeeperServer, InetSocketAddress... zooKeeperServers) { this(sessionTimeout, combine(zooKeeperServer, zooKeeperServers)); } /** * Creates an unconnected client that will lazily attempt to connect on the first call to * {@link #get}. * * @param sessionTimeout the ZK session timeout * @param zooKeeperServers the set of servers forming the ZK cluster */ public ZooKeeperClient(Amount<Integer, Time> sessionTimeout, Iterable<InetSocketAddress> zooKeeperServers) { this(sessionTimeout, Credentials.NONE, zooKeeperServers); } /** * Creates an unconnected client that will lazily attempt to connect on the first call to * {@link #get()}. All successful connections will be authenticated with the given * {@code credentials}. * * @param sessionTimeout the ZK session timeout * @param credentials the credentials to authenticate with * @param zooKeeperServer the first, required ZK server * @param zooKeeperServers any additional servers forming the ZK cluster */ public ZooKeeperClient(Amount<Integer, Time> sessionTimeout, Credentials credentials, InetSocketAddress zooKeeperServer, InetSocketAddress... zooKeeperServers) { this(sessionTimeout, credentials, combine(zooKeeperServer, zooKeeperServers)); } /** * Creates an unconnected client that will lazily attempt to connect on the first call to * {@link #get}. All successful connections will be authenticated with the given * {@code credentials}. * * @param sessionTimeout the ZK session timeout * @param credentials the credentials to authenticate with * @param zooKeeperServers the set of servers forming the ZK cluster */ public ZooKeeperClient(Amount<Integer, Time> sessionTimeout, Credentials credentials, Iterable<InetSocketAddress> zooKeeperServers) { this.sessionTimeoutMs = Preconditions.checkNotNull(sessionTimeout).as(Time.MILLISECONDS); this.credentials = Preconditions.checkNotNull(credentials); Preconditions.checkNotNull(zooKeeperServers); Preconditions.checkArgument(!Iterables.isEmpty(zooKeeperServers), "Must present at least 1 ZK server"); Iterable<String> servers = Iterables.transform(ImmutableSet.copyOf(zooKeeperServers), InetSocketAddressHelper.INET_TO_STR); this.zooKeeperServers = Joiner.on(',').join(servers); } /** * Returns the current active ZK connection or establishes a new one if none has yet been * established or a previous connection was disconnected or had its session time out. This method * will attempt to re-use sessions when possible. Equivalent to: * <pre>get(Amount.of(0L, ...)</pre>. * * @return a connected ZooKeeper client * @throws ZooKeeperConnectionException if there was a problem connecting to the ZK cluster * @throws InterruptedException if interrupted while waiting for a connection to be established */ public synchronized ZooKeeper get() throws ZooKeeperConnectionException, InterruptedException { try { return get(WAIT_FOREVER); } catch (TimeoutException e) { InterruptedException interruptedException = new InterruptedException("Got an unexpected TimeoutException for 0 wait"); interruptedException.initCause(e); throw interruptedException; } } /** * Returns the current active ZK connection or establishes a new one if none has yet been * established or a previous connection was disconnected or had its session time out. This * method will attempt to re-use sessions when possible. * * @param connectionTimeout the maximum amount of time to wait for the connection to the ZK * cluster to be established; 0 to wait forever * @return a connected ZooKeeper client * @throws ZooKeeperConnectionException if there was a problem connecting to the ZK cluster * @throws InterruptedException if interrupted while waiting for a connection to be established * @throws TimeoutException if a connection could not be established within the configured * session timeout */ public synchronized ZooKeeper get(Amount<Long, Time> connectionTimeout) throws ZooKeeperConnectionException, InterruptedException, TimeoutException { if (zooKeeper == null) { final CountDownLatch connected = new CountDownLatch(1); Watcher watcher = new Watcher() { @Override public void process(WatchedEvent event) { switch (event.getType()) { // Guard the None type since this watch may be used as the default watch on calls by // the client outside our control. case None: switch (event.getState()) { case Expired: LOG.info("Zookeeper session expired. Event: " + event); close(); break; case SyncConnected: connected.countDown(); break; } } synchronized (watchers) { for (Watcher watcher : watchers) { watcher.process(event); } } } }; try { zooKeeper = (sessionState != null) ? new ZooKeeper(zooKeeperServers, sessionTimeoutMs, watcher, sessionState.sessionId, sessionState.sessionPasswd) : new ZooKeeper(zooKeeperServers, sessionTimeoutMs, watcher); } catch (IOException e) { throw new ZooKeeperConnectionException( "Problem connecting to servers: " + zooKeeperServers, e); } if (connectionTimeout.getValue() > 0) { if (!connected.await(connectionTimeout.as(Time.MILLISECONDS), TimeUnit.MILLISECONDS)) { close(); throw new TimeoutException("Timed out waiting for a ZK connection after " + connectionTimeout); } } else { try { connected.await(); } catch (InterruptedException ex) { LOG.info("Interrupted while waiting to connect to zooKeeper"); close(); throw ex; } } credentials.authenticate(zooKeeper); sessionState = new SessionState(zooKeeper.getSessionId(), zooKeeper.getSessionPasswd()); } return zooKeeper; } /** * Clients that need to re-establish state after session expiration can register an * {@code onExpired} command to execute. * * @param onExpired the {@code Command} to register * @return the new {@link Watcher} which can later be passed to {@link #unregister} for * removal. */ public Watcher registerExpirationHandler(final Command onExpired) { Watcher watcher = new Watcher() { @Override public void process(WatchedEvent event) { if (event.getType() == EventType.None && event.getState() == KeeperState.Expired) { onExpired.execute(); } } }; register(watcher); return watcher; } /** * Clients that need to register a top-level {@code Watcher} should do so using this method. The * registered {@code watcher} will remain registered across re-connects and session expiration * events. * * @param watcher the {@code Watcher to register} */ public void register(Watcher watcher) { watchers.add(watcher); } /** * Clients can attempt to unregister a top-level {@code Watcher} that has previously been * registered. * * @param watcher the {@code Watcher} to unregister as a top-level, persistent watch * @return whether the given {@code Watcher} was found and removed from the active set */ public boolean unregister(Watcher watcher) { return watchers.remove(watcher); } /** * Checks to see if the client might reasonably re-try an operation given the exception thrown * while attempting it. If the ZooKeeper session should be expired to enable the re-try to * succeed this method will expire it as a side-effect. * * @param e the exception to test * @return true if a retry can be attempted */ public boolean shouldRetry(KeeperException e) { if (e instanceof SessionExpiredException) { close(); } return ZooKeeperUtils.isRetryable(e); } /** * Closes the current connection if any expiring the current ZooKeeper session. Any subsequent * calls to this method will no-op until the next successful {@link #get}. */ public synchronized void close() { if (zooKeeper != null) { try { zooKeeper.close(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.warning("Interrupted trying to close zooKeeper"); } finally { zooKeeper = null; sessionState = null; } } } @VisibleForTesting synchronized boolean isClosed() { return zooKeeper == null; } @VisibleForTesting ZooKeeper getZooKeeperClientForTests() { return zooKeeper; } }