/*
* Copyright MapR Technologies, $year
*
* 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.mapr.franz.catcher;
import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.protobuf.RpcController;
import com.google.protobuf.ServiceException;
import com.googlecode.protobuf.pro.duplex.PeerInfo;
import com.mapr.franz.catcher.wire.Catcher;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.*;
public class ClientTest {
// check for failure if we can't get to any server
@Test
public void noServer() throws ServiceException, IOException {
Client c = new Client(new ConnectionFactory() {
@Override
public CatcherConnection create(PeerInfo server) {
return null;
}
}, Lists.newArrayList(new PeerInfo("foo", 0)));
try {
c.sendMessage("topic", "message");
fail("Should have failed with IOException");
} catch (IOException e) {
assertTrue(e.getMessage().contains("No catcher servers"));
}
}
// check that we try a second time and that we get a good result the second time
@Test
public void oneServerRetry() throws ServiceException, IOException {
final ServerFarm farm = new ServerFarm();
// new MockUp<CatcherConnection>() {
// CatcherConnection it;
//
// @Mock(maxInvocations = 10)
// public void $init(PeerInfo host) throws IOException {
// it.setServer(host);
// }
//
// @Mock
// public Catcher.CatcherService.BlockingInterface getService() {
// return new FarmedServer(new Client.HostPort(new PeerInfo("foo", 123)), new SecureRandom().nextLong(), farm);
// }
//
// @Mock
// public String toString() {
// return "MockConnection(" + it.getServer() + ")";
// }
// };
Client c = new Client(new ConnectionFactory() {
int retry = 0;
@Override
public CatcherConnection create(PeerInfo server) throws IOException {
if (retry++ == 0) {
return null;
} else {
return new FakeConnection(server, farm);
}
}
}, Lists.newArrayList(new PeerInfo("foo", 0)));
c.sendMessage("3", "message");
assertEquals(1, farm.getMessages().size());
assertEquals("message", farm.getMessages().get(0));
}
// verifies redirects are remembered by the client
// also verifies that server failures are dealt with only a few redirects
// also verifies that overall transaction counts are evenly distributed
@Test
public void redirects() throws ServiceException, IOException {
final ServerFarm farm = new ServerFarm();
final Map<CatcherConnection, FarmedServer> servermap = Maps.newHashMap();
final Map<CatcherConnection, PeerInfo> hostmap = Maps.newHashMap();
// new MockUp<CatcherConnection>() {
// CatcherConnection it;
//
// @Mock(maxInvocations = 10)
// public void $init(PeerInfo host) throws IOException {
// servermap.put(it, farm.newServer(new Client.HostPort(host)));
// hostmap.put(it, host);
// it.setServer(host);
// }
//
// @Mock
// public Catcher.CatcherService.BlockingInterface getService() {
// return servermap.get(it);
// }
//
// @Mock
// public String toString() {
// return "MockConnection(" + hostmap.get(it) + ")";
// }
// };
List<PeerInfo> hosts = Lists.newArrayList();
for (int i = 0; i < 10; i++) {
hosts.add(new PeerInfo(Integer.toString(i), 100));
}
Client c = new Client(new ConnectionFactory() {
@Override
public CatcherConnection create(final PeerInfo server) throws IOException {
return new FakeConnection(server, farm);
}
}, hosts);
for (int i = 0; i < 600; i++) {
int topic = i % 30;
c.sendMessage(Integer.toString(topic), "message " + i);
}
assertEquals(600, countMessages(farm.servers));
assertEquals(600, farm.messageCount);
int firstPassRedirects = farm.redirectCount;
assertTrue("Should have few redirects, got " + firstPassRedirects, firstPassRedirects <= 30);
assertEquals(10, farm.helloCount);
// assert rough balance of traffic
for (FarmedServer server : farm.servers) {
assertEquals(600 / 10, server.getMessageCount(), 15);
}
// kill 4 servers. That leaves 6 which still divides 30 topics evenly.
List<FarmedServer> dead = Lists.newArrayList();
dead.addAll(farm.servers.subList(6, 10));
farm.servers = farm.servers.subList(0, 6);
int deadCount = countMessages(dead);
for (FarmedServer server : dead) {
server.emulateServerFailure();
}
for (int i = 0; i < 600; i++) {
int topic = i % 30;
c.sendMessage(Integer.toString(topic), "message " + i);
}
assertEquals(2 * 600, farm.messageCount);
assertEquals(deadCount, countMessages(dead));
assertEquals(2 * 600, countMessages(Iterables.concat(farm.servers, dead)));
// and again, should have rough balance.
for (FarmedServer server : farm.servers) {
assertEquals(600 / 10 + 600 / 6, server.getMessageCount(), 24);
}
assertEquals(2 * 600, farm.messageCount);
assertTrue("Should have few redirects", farm.redirectCount - firstPassRedirects <= 30);
assertEquals(10, farm.helloCount);
c.close();
}
// TODO add test to see how new hosts discovered during hello are handled.
private int countMessages(Iterable<FarmedServer> servers) {
int count = 0;
for (FarmedServer server : servers) {
count += server.getMessageCount();
}
return count;
}
private static class FakeConnection implements CatcherConnection {
private final FarmedServer service;
private final PeerInfo server;
private FakeConnection(PeerInfo server, ServerFarm farm) throws IOException {
service = farm.newServer(new Client.HostPort(server));
this.server = server;
}
public Catcher.CatcherService.BlockingInterface getService() {
return service;
}
@Override
public RpcController getController() {
return null;
}
@Override
public PeerInfo getServer() {
return server;
}
@Override
public void close() {
// ignore
}
@Override
public void setServer(PeerInfo host) {
throw new RuntimeException("Shouldn't call this");
}
public String toString() {
return "MockConnection(" + server + ")";
}
}
/**
* Keeps track of a bunch of FarmedServer's and the associated transaction counts.
*/
private static class ServerFarm implements Iterable<FarmedServer> {
private Logger logger = LoggerFactory.getLogger(String.class);
private List<FarmedServer> servers = Lists.newArrayList();
private int redirectCount = 0;
private int messageCount = 0;
private int helloCount = 0;
final List<String> messages = Lists.newArrayList();
private Set<Client.HostPort> deadHosts = Sets.newHashSet();
public FarmedServer newServer(Client.HostPort hostPort) throws IOException {
if (!deadHosts.contains(hostPort)) {
FarmedServer r = new FarmedServer(hostPort, servers.size(), this);
servers.add(r);
return r;
} else {
throw new IOException("Connection refused :-)");
}
}
public int size() {
return servers.size();
}
public void notifyHello(long id) {
logger.debug("Hello {}", id);
helloCount++;
}
public void notifyClose(long id) {
logger.debug("Closing {}", id);
}
public void notifyMessage(long id, String topic, String message) {
logger.debug("Message received at {} on topic {}", id, topic);
messages.add(message);
messageCount++;
}
public void notifyRedirect(long from, long to) {
logger.debug("Redirect from {} to {}", from, to);
redirectCount++;
}
@Override
public Iterator<FarmedServer> iterator() {
return servers.iterator();
}
public List<String> getMessages() {
return messages;
}
public void recordDeadHost(Client.HostPort hostPort) {
deadHosts.add(hostPort);
}
}
/**
* Emulates minimal server function and allows emulation of failures.
*/
private static class FarmedServer implements Catcher.CatcherService.BlockingInterface {
protected int helloCount = 0;
protected int redirectCount = 0;
protected int messageCount = 0;
private Client.HostPort hostPort;
private final long id;
private final ServerFarm farm;
private boolean isDead = false;
private FarmedServer(Client.HostPort hostPort, long id, ServerFarm farm) {
this.hostPort = hostPort;
this.id = id;
this.farm = farm;
}
@Override
public Catcher.HelloResponse hello(RpcController controller, Catcher.Hello request) throws ServiceException {
checkForFailure();
helloCount++;
if (farm != null) {
farm.notifyHello(id);
}
Catcher.HelloResponse.Builder r = Catcher.HelloResponse
.newBuilder().setServerId(id);
for (FarmedServer server : farm) {
Catcher.Server.Builder s = r.addClusterBuilder();
s.setServerId(server.getId());
s.addHostBuilder().setHostName(server.getHost()).setPort(server.getPort()).build();
s.build();
}
return r.build();
}
@Override
public Catcher.LogMessageResponse log(RpcController controller, Catcher.LogMessage request) throws ServiceException {
Preconditions.checkArgument(request.hasPayload());
checkForFailure();
String topic = request.getTopic();
farm.notifyMessage(id, topic, request.getPayload().toStringUtf8());
int topicNumber = Integer.parseInt(topic);
messageCount++;
Catcher.LogMessageResponse.Builder r = Catcher.LogMessageResponse.newBuilder()
.setServerId(id)
.setSuccessful(true);
if (farm.size() != 0) {
FarmedServer redirectServer = farm.servers.get(topicNumber % farm.size());
long redirectTo = redirectServer.getId();
if (redirectTo != getId()) {
redirectCount++;
getFarm().notifyRedirect(getId(), redirectTo);
Catcher.TopicMapping.Builder redirect = r.getRedirectBuilder();
redirect.setTopic(Integer.toString(topicNumber));
Catcher.Server.Builder server = redirect.getServerBuilder().setServerId(redirectTo);
server.addHostBuilder()
.setHostName(redirectServer.getHost())
.setPort(redirectServer.getPort())
.build();
server.build();
}
}
return r.build();
}
@Override
public Catcher.CloseResponse close(RpcController controller, Catcher.Close request) throws ServiceException {
checkForFailure();
farm.notifyClose(id);
return Catcher.CloseResponse.newBuilder().setServerId(id).build();
}
public ServerFarm getFarm() {
return farm;
}
public long getId() {
return id;
}
public void checkForFailure() throws ServiceException {
if (isDead) {
throw new ServiceException("Simulated server shutdown");
}
}
public void emulateServerFailure() {
farm.recordDeadHost(hostPort);
isDead = true;
}
public String getHost() {
return hostPort.getHost();
}
public int getPort() {
return hostPort.getPort();
}
public int getMessageCount() {
return messageCount;
}
}
// TODO two server topic redirects work correctly (two servers, 100 requests on 10 topics, each topic has at most 1 request to wrong server)
// TODO three server connect, cluster shrinks, send remaining topics to remaining nodes, minimal redirects
// TODO three server connect, cluster shrinks, then expands, minimal redirects
}