// =================================================================================================
// 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.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.regex.Pattern;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang.StringUtils;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.ZooDefs.Ids;
import org.apache.zookeeper.data.ACL;
import org.junit.Before;
import org.junit.Test;
import com.twitter.common.base.Command;
import com.twitter.common.base.Supplier;
import com.twitter.common.quantity.Amount;
import com.twitter.common.quantity.Time;
import com.twitter.common.testing.EasyMockTest;
import com.twitter.common.zookeeper.Group.GroupChangeListener;
import com.twitter.common.zookeeper.Group.JoinException;
import com.twitter.common.zookeeper.Group.Membership;
import com.twitter.common.zookeeper.ZooKeeperClient.Credentials;
import com.twitter.common.zookeeper.testing.BaseZooKeeperTest;
import com.twitter.common.zookeeper.ZooKeeperClient.ZooKeeperConnectionException;
import static com.google.common.testing.junit4.JUnitAsserts.assertNotEqual;
import static org.easymock.EasyMock.createMock;
import static org.easymock.EasyMock.expect;
import static org.easymock.EasyMock.replay;
import static org.easymock.EasyMock.reset;
import static org.easymock.EasyMock.verify;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* @author John Sirois
*/
public class GroupTest extends BaseZooKeeperTest {
private ZooKeeperClient zkClient;
private Group group;
private com.twitter.common.base.Command onLoseMembership;
private RecordingListener listener;
public GroupTest() {
super(Amount.of(1, Time.DAYS));
}
@Before
public void mySetUp() throws Exception {
onLoseMembership = createMock(Command.class);
zkClient = createZkClient("group", "test");
group = new Group(zkClient, ZooKeeperUtils.EVERYONE_READ_CREATOR_ALL, "/a/group");
listener = new RecordingListener();
group.watch(listener);
}
private static class RecordingListener implements GroupChangeListener {
private final LinkedBlockingQueue<Iterable<String>> membershipChanges =
new LinkedBlockingQueue<Iterable<String>>();
@Override
public void onGroupChange(Iterable<String> memberIds) {
membershipChanges.add(memberIds);
}
public Iterable<String> take() throws InterruptedException {
return membershipChanges.take();
}
public boolean isEmpty() {
return membershipChanges.isEmpty();
}
@Override
public String toString() {
return membershipChanges.toString();
}
}
private static class CustomNamingScheme implements Group.NodeNameScheme {
public static final String NODENAME = "custom_name";
private Predicate<String> nodeNameFilter;
public CustomNamingScheme() {
final Pattern groupNodeNamePattern = Pattern.compile("^" + Pattern.quote(NODENAME));
nodeNameFilter = new Predicate<String>() {
@Override public boolean apply(String childNodeName) {
return groupNodeNamePattern.matcher(childNodeName).matches();
}
};
}
@Override
public Predicate<String> getNodeNameFilter() {
return nodeNameFilter;
}
@Override
public String createNodePath(ZooKeeperClient zkClient, String path, byte[] membershipData,
ImmutableList<ACL> acl) throws ZooKeeperConnectionException, KeeperException,
InterruptedException {
return zkClient.get().create(path + "/" + NODENAME, membershipData, acl,
CreateMode.EPHEMERAL);
}
@Override
public String extractMemberId(String nodePath) {
String memberId = StringUtils.substringAfterLast(nodePath, "/");
return memberId;
}
}
@Test
public void testLenientPaths() {
assertEquals("/", Group.normalizePath("///"));
assertEquals("/a/group", Group.normalizePath("/a/group"));
assertEquals("/a/group", Group.normalizePath("/a/group/"));
assertEquals("/a/group", Group.normalizePath("/a//group"));
assertEquals("/a/group", Group.normalizePath("/a//group//"));
try {
Group.normalizePath("a/group");
fail("Relative paths should not be allowed.");
} catch (IllegalArgumentException e) {
// expected
}
try {
Group.normalizePath("/a/./group");
fail("Relative paths should not be allowed.");
} catch (IllegalArgumentException e) {
// expected
}
try {
Group.normalizePath("/a/../group");
fail("Relative paths should not be allowed.");
} catch (IllegalArgumentException e) {
// expected
}
}
@Test
public void testSessionExpirationTriggersOnLoseMembership() throws Exception {
final CountDownLatch lostMembership = new CountDownLatch(1);
Command onLoseMembership = new Command() {
@Override public void execute() throws RuntimeException {
lostMembership.countDown();
}
};
assertEmptyMembershipObserved();
Membership membership = group.join(onLoseMembership);
assertMembershipObserved(membership.getMemberId());
expireSession(zkClient);
lostMembership.await(); // Will hang this test if onLoseMembership event is not propagated.
}
@Test
public void testNodeDeleteTriggersOnLoseMembership() throws Exception {
final CountDownLatch lostMembership = new CountDownLatch(1);
Command onLoseMembership = new Command() {
@Override public void execute() throws RuntimeException {
lostMembership.countDown();
}
};
assertEmptyMembershipObserved();
Membership membership = group.join(onLoseMembership);
assertMembershipObserved(membership.getMemberId());
membership.cancel();
lostMembership.await(); // Will hang this test if onLoseMembership event is not propagated.
}
@Test
public void testJoinsAndWatchesSurviveDisconnect() throws Exception {
replay(onLoseMembership);
assertEmptyMembershipObserved();
Membership membership = group.join();
String originalMemberId = membership.getMemberId();
assertMembershipObserved(originalMemberId);
shutdownNetwork();
restartNetwork();
// The member should still be present under existing ephemeral node since session did not
// expire.
group.watch(listener);
assertMembershipObserved(originalMemberId);
membership.cancel();
assertEmptyMembershipObserved();
assertEmptyMembershipObserved(); // and again for 2nd listener
assertTrue(listener.isEmpty());
verify(onLoseMembership);
reset(onLoseMembership); // Turn off expectations during ZK server shutdown.
}
@Test
public void testJoinsAndWatchesSurviveExpiredSession() throws Exception {
onLoseMembership.execute();
replay(onLoseMembership);
assertEmptyMembershipObserved();
Membership membership = group.join(onLoseMembership);
String originalMemberId = membership.getMemberId();
assertMembershipObserved(originalMemberId);
expireSession(zkClient);
// We should have lost our group membership and then re-gained it with a new ephemeral node.
// We may or may-not see the intermediate state change but we must see the final state
Iterable<String> members = listener.take();
if (Iterables.isEmpty(members)) {
members = listener.take();
}
assertEquals(1, Iterables.size(members));
assertNotEqual(originalMemberId, Iterables.getOnlyElement(members));
assertNotEqual(originalMemberId, membership.getMemberId());
assertTrue(listener.isEmpty());
verify(onLoseMembership);
reset(onLoseMembership); // Turn off expectations during ZK server shutdown.
}
@Test
public void testJoinCustomNamingScheme() throws Exception {
group = new Group(zkClient, ZooKeeperUtils.EVERYONE_READ_CREATOR_ALL, "/a/group", new CustomNamingScheme());
listener = new RecordingListener();
group.watch(listener);
assertEmptyMembershipObserved();
Membership membership = group.join();
String memberId = membership.getMemberId();
assertEquals("Wrong member ID.", CustomNamingScheme.NODENAME, memberId);
assertMembershipObserved(memberId);
expireSession(zkClient);
}
@Test
public void testUpdateMembershipData() throws Exception {
Supplier<byte[]> dataSupplier = new EasyMockTest.Clazz<Supplier<byte[]>>() {}.createMock();
byte[] initial = "start".getBytes();
expect(dataSupplier.get()).andReturn(initial);
byte[] second = "update".getBytes();
expect(dataSupplier.get()).andReturn(second);
replay(dataSupplier);
Membership membership = group.join(dataSupplier, onLoseMembership);
assertArrayEquals("Initial setting is incorrect.", initial, zkClient.get()
.getData(membership.getMemberPath(), false, null));
assertArrayEquals("Updating supplier should not change membership data",
initial, zkClient.get().getData(membership.getMemberPath(), false, null));
membership.updateMemberData();
assertArrayEquals("Updating membership should change data",
second, zkClient.get().getData(membership.getMemberPath(), false, null));
verify(dataSupplier);
}
@Test
public void testAcls() throws Exception {
Group securedMembership =
new Group(createZkClient("secured", "group"), ZooKeeperUtils.EVERYONE_READ_CREATOR_ALL,
"/secured/group/membership");
String memberId = securedMembership.join().getMemberId();
Group unauthenticatedObserver =
new Group(createZkClient(Credentials.NONE),
Ids.READ_ACL_UNSAFE,
"/secured/group/membership");
RecordingListener unauthenticatedListener = new RecordingListener();
unauthenticatedObserver.watch(unauthenticatedListener);
assertMembershipObserved(unauthenticatedListener, memberId);
try {
unauthenticatedObserver.join();
fail("Expected join exception for unauthenticated observer");
} catch (JoinException e) {
// expected
}
Group unauthorizedObserver =
new Group(createZkClient("joe", "schmoe"),
Ids.READ_ACL_UNSAFE,
"/secured/group/membership");
RecordingListener unauthorizedListener = new RecordingListener();
unauthorizedObserver.watch(unauthorizedListener);
assertMembershipObserved(unauthorizedListener, memberId);
try {
unauthorizedObserver.join();
fail("Expected join exception for unauthorized observer");
} catch (JoinException e) {
// expected
}
}
private void assertEmptyMembershipObserved() throws InterruptedException {
Iterable<String> membershipChange = listener.take();
assertTrue("Expected an empty membershipChange, got: " + membershipChange + " queued: " +
listener,
Iterables.isEmpty(membershipChange));
}
private void assertMembershipObserved(String expectedMemberId) throws InterruptedException {
assertMembershipObserved(listener, expectedMemberId);
}
private void assertMembershipObserved(RecordingListener listener, String expectedMemberId)
throws InterruptedException {
Iterable<String> members = listener.take();
assertEquals(1, Iterables.size(members));
assertEquals(expectedMemberId, Iterables.getOnlyElement(members));
}
}