/*
* Visage
* Copyright (c) 2015-2016, Aesen Vismea <aesen@unascribed.com>
*
* The MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.surgeplay.visage.master;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.ObjectInputStream;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.zip.DeflaterOutputStream;
import org.eclipse.jetty.server.AsyncNCSARequestLog;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.server.handler.gzip.GzipHandler;
import org.eclipse.jetty.util.log.Log;
import org.spacehq.mc.auth.GameProfile;
import org.spacehq.mc.auth.util.URLUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import com.google.common.collect.Maps;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closer;
import com.rabbitmq.client.AMQP.BasicProperties;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
import com.rabbitmq.client.QueueingConsumer.Delivery;
import com.surgeplay.visage.RenderMode;
import com.surgeplay.visage.Visage;
import com.surgeplay.visage.VisageRunner;
import com.surgeplay.visage.master.exception.NoSlavesAvailableException;
import com.surgeplay.visage.master.exception.RenderFailedException;
import com.surgeplay.visage.master.glue.HeaderHandler;
import com.surgeplay.visage.master.glue.LogShim;
import com.surgeplay.visage.slave.VisageSlave;
import com.surgeplay.visage.util.Profiles;
import com.typesafe.config.Config;
public class VisageMaster extends Thread implements VisageRunner {
public VisageSlave fallback;
public Config config;
public Connection conn;
public Channel channel;
public byte[] steve, alex;
private JedisPool pool;
private int resolverNum, skinNum;
private String password;
private boolean run = true;
public VisageMaster(Config config) {
super("Master thread");
this.config = config;
}
public Jedis getResolverJedis() {
Jedis j = getJedis();
j.select(resolverNum);
return j;
}
public Jedis getSkinJedis() {
Jedis j = getJedis();
j.select(skinNum);
return j;
}
public Jedis getJedis() {
Jedis j = pool.getResource();
if (password != null) {
j.auth(password);
}
return j;
}
@Override
public void run() {
try {
Log.setLog(new LogShim(Visage.log));
long total = Runtime.getRuntime().totalMemory();
long max = Runtime.getRuntime().maxMemory();
if (Visage.debug) Visage.log.finer("Current heap size: "+humanReadableByteCount(total, false));
if (Visage.debug) Visage.log.finer("Max heap size: "+humanReadableByteCount(max, false));
if (total < max) {
Visage.log.warning("You have set your minimum heap size (Xms) lower than the maximum heap size (Xmx) - this can cause GC thrashing. It is strongly recommended to set them both to the same value.");
}
if (max < (1000*1000*1000)) {
Visage.log.warning("The heap size (Xmx) is less than one gigabyte; it is recommended to run Visage with a gigabyte or more. Use -Xms1G and -Xmx1G to do this.");
}
Visage.log.info("Setting up Jetty");
Server server = new Server(new InetSocketAddress(config.getString("http.bind"), config.getInt("http.port")));
List<String> expose = config.getStringList("expose");
String poweredBy;
if (expose.contains("server")) {
if (expose.contains("version")) {
poweredBy = "Visage v"+Visage.VERSION;
} else {
poweredBy = "Visage";
}
} else {
poweredBy = null;
}
ResourceHandler resource = new ResourceHandler();
resource.setResourceBase(config.getString("http.static"));
resource.setDirectoriesListed(false);
resource.setWelcomeFiles(new String[] {"index.html"});
resource.setHandler(new VisageHandler(this));
if (!"/dev/null".equals(config.getString("log"))) {
new File(config.getString("log")).getParentFile().mkdirs();
server.setRequestLog(new AsyncNCSARequestLog(config.getString("log")));
}
GzipHandler gzip = new GzipHandler();
gzip.setHandler(new HeaderHandler("X-Powered-By", poweredBy, resource));
server.setHandler(gzip);
String redisHost = config.getString("redis.host");
int redisPort = config.getInt("redis.port");
Visage.log.info("Connecting to Redis at "+redisHost+":"+redisPort);
resolverNum = config.getInt("redis.resolver-db");
skinNum = config.getInt("redis.skin-db");
JedisPoolConfig jpc = new JedisPoolConfig();
jpc.setMaxIdle(config.getInt("redis.max-idle-connections"));
jpc.setMaxTotal(config.getInt("redis.max-total-connections"));
jpc.setMinIdle(config.getInt("redis.min-idle-connections"));
if (config.hasPath("redis.password")) {
password = config.getString("redis.password");
}
pool = new JedisPool(jpc, redisHost, redisPort);
Visage.log.info("Connecting to RabbitMQ at "+config.getString("rabbitmq.host")+":"+config.getInt("rabbitmq.port"));
ConnectionFactory factory = new ConnectionFactory();
factory.setHost(config.getString("rabbitmq.host"));
factory.setPort(config.getInt("rabbitmq.port"));
factory.setRequestedHeartbeat(10);
if (config.hasPath("rabbitmq.user")) {
factory.setUsername(config.getString("rabbitmq.user"));
factory.setPassword(config.getString("rabbitmq.password"));
}
String queue = config.getString("rabbitmq.queue");
Closer closer = Closer.create();
steve = ByteStreams.toByteArray(closer.register(ClassLoader.getSystemResourceAsStream("steve.png")));
alex = ByteStreams.toByteArray(closer.register(ClassLoader.getSystemResourceAsStream("alex.png")));
closer.close();
conn = factory.newConnection();
channel = conn.createChannel();
if (Visage.debug) Visage.log.finer("Setting up queue '"+queue+"'");
channel.queueDeclare(queue, false, false, true, null);
channel.basicQos(1);
if (Visage.debug) Visage.log.finer("Setting up reply queue");
replyQueue = channel.queueDeclare().getQueue();
consumer = new QueueingConsumer(channel);
channel.basicConsume(replyQueue, consumer);
if (config.getBoolean("slave.enable")) {
Visage.log.info("Starting fallback slave");
fallback = new VisageSlave(config.getConfig("slave").withValue("rabbitmq", config.getValue("rabbitmq")));
fallback.start();
}
Visage.log.info("Starting Jetty");
server.start();
Visage.log.info("Listening for finished jobs");
try {
while (run) {
Delivery delivery = consumer.nextDelivery();
if (Visage.trace) Visage.log.finest("Got delivery");
try {
String corrId = delivery.getProperties().getCorrelationId();
if (queuedJobs.containsKey(corrId)) {
if (Visage.trace) Visage.log.finest("Valid");
responses.put(corrId, delivery.getBody());
Runnable run = queuedJobs.get(corrId);
queuedJobs.remove(corrId);
if (Visage.trace) Visage.log.finest("Removed from queue");
run.run();
if (Visage.trace) Visage.log.finest("Ran runnable");
channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
if (Visage.trace) Visage.log.finest("Ack'd");
} else {
Visage.log.warning("Unknown correlation ID?");
channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false);
}
} catch (Exception e) {
Visage.log.log(Level.WARNING, "An unexpected error occured while attempting to process a response.", e);
}
}
} catch (InterruptedException e) {
} catch (Exception e) {
Visage.log.log(Level.SEVERE, "An unexpected error occured in the master run loop.", e);
System.exit(2);
}
try {
Visage.log.info("Shutting down master");
server.stop();
pool.destroy();
conn.close(5000);
} catch (Exception e) {
Visage.log.log(Level.SEVERE, "A fatal error has occurred while shutting down the master.", e);
}
} catch (Exception e) {
Visage.log.log(Level.SEVERE, "An unexpected error occured while initializing the master.", e);
System.exit(1);
}
}
private String replyQueue;
private QueueingConsumer consumer;
private Map<String, Runnable> queuedJobs = Maps.newHashMap();
private Map<String, byte[]> responses = Maps.newHashMap();
public RenderResponse renderRpc(RenderMode mode, int width, int height, int supersampling, GameProfile profile, byte[] skin, Map<String, String[]> switches) throws RenderFailedException, NoSlavesAvailableException {
if (mode == RenderMode.SKIN) return null;
try {
byte[] response = null;
String corrId = UUID.randomUUID().toString();
BasicProperties props = new BasicProperties.Builder().correlationId(corrId).replyTo(replyQueue).build();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DeflaterOutputStream defos = new DeflaterOutputStream(baos);
DataOutputStream dos = new DataOutputStream(defos);
dos.writeByte(mode.ordinal());
dos.writeShort(width);
dos.writeShort(height);
dos.writeByte(supersampling);
Profiles.writeGameProfile(dos, profile);
dos.writeShort(switches.size());
for (Entry<String, String[]> en : switches.entrySet()) {
dos.writeUTF(en.getKey());
dos.writeByte(en.getValue().length);
for (String s : en.getValue()) {
dos.writeUTF(s);
}
}
dos.writeInt(skin.length);
dos.write(skin);
dos.flush();
defos.finish();
channel.basicPublish("", config.getString("rabbitmq.queue"), props, baos.toByteArray());
if (Visage.debug) Visage.log.finer("Requested a "+width+"x"+height+" "+mode.name().toLowerCase()+" render ("+supersampling+"x supersampling) for "+(profile == null ? "null" : profile.getName()));
final Object waiter = new Object();
queuedJobs.put(corrId, new Runnable() {
@Override
public void run() {
if (Visage.debug) Visage.log.finer("Got response");
synchronized (waiter) {
waiter.notify();
}
}
});
long start = System.currentTimeMillis();
long timeout = config.getDuration("render.timeout", TimeUnit.MILLISECONDS);
synchronized (waiter) {
while (queuedJobs.containsKey(corrId) && (System.currentTimeMillis()-start) < timeout) {
if (Visage.trace) Visage.log.finest("Waiting...");
waiter.wait(timeout);
}
}
if (queuedJobs.containsKey(corrId)) {
if (Visage.trace) Visage.log.finest("Queue still contains this request, assuming timeout");
queuedJobs.remove(corrId);
throw new RenderFailedException("Request timed out");
}
response = responses.get(corrId);
responses.remove(corrId);
if (response == null)
throw new RenderFailedException("Response was null");
ByteArrayInputStream bais = new ByteArrayInputStream(response);
String slave = new DataInputStream(bais).readUTF();
int type = bais.read();
byte[] payload = ByteStreams.toByteArray(bais);
if (type == 0) {
if (Visage.trace) Visage.log.finest("Got type 0, success");
RenderResponse resp = new RenderResponse();
resp.slave = slave;
resp.png = payload;
Visage.log.info("Receieved a "+mode.name().toLowerCase()+" render from "+resp.slave);
return resp;
} else if (type == 1) {
if (Visage.trace) Visage.log.finest("Got type 1, failure");
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(payload));
Throwable t = (Throwable)ois.readObject();
throw new RenderFailedException("Slave reported error", t);
} else
throw new RenderFailedException("Malformed response from '"+slave+"' - unknown response id "+type);
} catch (Exception e) {
if (e instanceof RenderFailedException)
throw (RenderFailedException) e;
throw new RenderFailedException("Unexpected error", e);
}
}
/**
* <a href="http://stackoverflow.com/questions/3758606/how-to-convert-byte-size-into-human-readable-format-in-java">Source</a>
* @author aioobe
*/
public static String humanReadableByteCount(long bytes, boolean si) {
int unit = si ? 1000 : 1024;
if (bytes < unit) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(unit));
String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre);
}
@Override
public void shutdown() {
run = false;
interrupt();
}
}