Enterprise Java

Distributed Quasar Actors with Kafka and ZeroMQ

So you’ve got a fancy design using actors, you’ve chosen the JVM and Quasar’s powerful, loyal take on the subject. All wise decisions, but then what are your options for distributing them on a cluster?

Galaxy

Galaxy is a really cool option: a fast in-memory data grid optimized for data locality with replication, optional persistence, a distributed actor registry and even actors migration between nodes! There’s only one caveat: it will take another couple of months before a we release a production-quality, formally-verified, version of Galaxy. The current version of Galaxy is not recommended for production use.

What if we need to go live before that?

Luckily Quasar Actors’ blocking programming model is so straightforward that integrating it with most messaging solutions is a breeze, and in order to demonstrate that let’s do it with two fast, popular and very different ones: Apache Kafka and ØMQ.

The code and the plan

All of the following examples can be found on GitHub, just give a quick look at the short README and you’ll be running them in no time.

There are two examples for each of Kafka and ØMQ:

  • A quick-and-dirty one performing straight publish/poll or send/receive calls from actors.
  • A more elaborate one going through proxy actors that shield your code from the messaging APIs. As a proof that I’m not lying this program uses the same producer and consumer actor classes for both technologies and almost the same bootstrap program.

Kafka

Apache Kafka has seen a steep rise in adoption due to its unique design based on commit logs for durability and consumer groups for parallel message consumption: this combination resulted in a fast, reliable, flexible and scalable broker.

The API includes two flavors of producers, sync and async, and one of consumers (only sync); Comsat includes a community-contributed, fiber-friendly Kafka producer integration.

A Kafka producer handle is thread-safe, performs best when shared and can be obtained and used easily in an actor body (or anywhere else) like so:

final Properties producerConfig = new Properties();
producerConfig.put("bootstrap.servers", "localhost:9092");
producerConfig.put("client.id", "DemoProducer");
producerConfig.put("key.serializer", "org.apache.kafka.common.serialization.IntegerSerializer");
producerConfig.put("value.serializer", "org.apache.kafka.common.serialization.ByteArraySerializer");

try (final FiberKafkaProducer<Integer, byte[]> producer = new FiberKafkaProducer<>(new KafkaProducer<>(producerConfig))) {
     final byte[] myBytes = getMyBytes(); // ...
     final Future<RecordMetaData> res = producer.send(new ProducerRecord<>("MyTopic", i, myBytes));
     res.get(); // Optional, blocks the fiber until the record is persisted; thre's also `producer.flush()`
}

We’re wrapping the KafkaProducer object with Comsat’s FiberKafkaProducer in order to get back a fiber-blocking future.

A consumer handle, however, is not thread-safe 1 and is thread-blocking only:

final Properties producerConfig = new Properties();
consumerConfig = new Properties();
consumerConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP);
consumerConfig.put(ConsumerConfig.GROUP_ID_CONFIG, "DemoConsumer");
consumerConfig.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
consumerConfig.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
consumerConfig.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.IntegerDeserializer");
consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer");

try (final Consumer<Integer, byte[]> consumer = new KafkaConsumer<>(consumerConfig)) {
    consumer.subscribe(Collections.singletonList(TOPIC));
    final ConsumerRecords<Integer, byte[]> records = consumer.poll(1000L);
    for (final ConsumerRecord<Integer, byte[]> record : records) {
        final byte[] v = record.value();
        useMyBytes(v); // ...
    }
}

As we don’t want to block the fiber’s underlying thread pool (besides the ones Kafka blocks under the cover – we can’t do much about them), in our actor’s doRun we’ll use instead FiberAsync.runBlocking to feed a fixed-size executor with an async task that will just block the fiber until poll (which will execute in the given pool) returns:

final ExecutorService e = Executors.newFixedThreadPool(2);

try (final Consumer<Integer, byte[]> consumer = new KafkaConsumer<>(consumerConfig)) {
    consumer.subscribe(Collections.singletonList(TOPIC));
    final ConsumerRecords<Integer, byte[]> records = call(e, () -> consumer.poll(1000L));
    for (final ConsumerRecord<Integer, byte[]> record : records) {
        final byte[] v = record.value();
        useMyBytes(v); // ...
    }
}

Where call is a utility method defined as follows (it wouldn’t have been necessary if not for this Java compiler bug):

@Suspendable
public static <V> V call(ExecutorService es, Callable<V> c) throws InterruptedException, SuspendExecution {
    try {
        return runBlocking(es, (CheckedCallable<V, Exception>) c::call);
    } catch (final InterruptedException | SuspendExecution e) {
        throw e;
    } catch (final Exception e) {
        throw new RuntimeException(e);
    }
}

In the first complete example we’re sending a thousand serialized messages from a producer actor to a consumer one.

ØMQ

ØMQ (or ZeroMQ) is not a centralized broker solution and is more of a generalization of sockets to various communication patterns (request/reply, pub/sub etc.). In our examples we’re going to use the simplest request-reply pattern. Here’s our new producer code:

try (final ZMQ.Context zmq = ZMQ.context(1 /* IO threads */);
     final ZMQ.Socket trgt = zmq.socket(ZMQ.REQ)) {
    trgt.connect("tcp://localhost:8000");
    final byte[] myBytes = getMyBytes(); // ...
    trgt.send(baos.toByteArray(), 0 /* flags */)
    trgt.recv(); // Reply, e.g. ACK
}

As you can see the context acts as a socket factory and is passed the number of I/O threads to be used: this is because ØMQ sockets are not connection-bound OS handles but rather a simple front-end to a machinery that will handle connection retry, multiple connections, efficient concurrent I/O and even queuing for you. This is the reason why send calls are almost never blocking and recv call is not an I/O call on a connection but rather a synchronization between your thread and a specialized I/O task that will hand incoming bytes from one or even several connections.

Rather than threads we’ll be blocking fibers in our actors though, so let’s use FiberAsync.runBlocking on read calls and, just in case it blocks, even on send ones:

final ExecutorService ep = Executors.newFixedThreadPool(2);

try (final ZMQ.Context zmq = ZMQ.context(1 /* IO threads */);
     final ZMQ.Socket trgt = zmq.socket(ZMQ.REQ)) {
    exec(e, () -> trgt.connect("tcp://localhost:8000"));
    final byte[] myBytes = getMyBytes(); // ...
    call(e, trgt.send(myBytes, 0 /* flags */));
    call(e, trgt::recv); // Reply, e.g. ACK
}

Here’s the consumer:

try (final ZMQ.Context zmq = ZMQ.context(1 /* IO threads */);
     final ZMQ.Socket src = zmq.socket(ZMQ.REP)) {
    exec(e, () -> src.bind("tcp://*:8000"));
    final byte[] v = call(e, src::recv);
    exec(e, () -> src.send("ACK"));
    useMyBytes(v); // ...
}

where exec is another utility function, similar to call:

@Suspendable
public static void exec(ExecutorService es, Runnable r) throws InterruptedException, SuspendExecution {
    try {
        runBlocking(es, (CheckedCallable<Void, Exception>) () -> { r.run(); return null; });
    } catch (final InterruptedException | SuspendExecution e) {
        throw e;
    } catch (final Exception e) {
        throw new RuntimeException(e);
    }
}

And here’s the full first example.

Distributing without changing the logic: loose coupling to the rescue

It’s straightforward, isn’t it? There’s something annoying though: we’re dealing with actors on the other side of the network quite differently from local ones. Here are the actors we’d like to write instead, no matter where they’re located nor how they’re connected:

public final class ProducerActor extends BasicActor<Void, Void> {
    private final ActorRef<Msg> target;

    public ProducerActor(ActorRef<Msg> target) {
        this.target = target;
    }

    @Override
    protected final Void doRun() throws InterruptedException, SuspendExecution {
        for (int i = 0; i < MSGS; i++) {
            final Msg m = new Msg(i);
            System.err.println("USER PRODUCER: " + m);
            target.send(m);
        }
        System.err.println("USER PRODUCER: " + EXIT);
        target.send(EXIT);
        return null;
    }
}
public final class ConsumerActor extends BasicActor<Msg, Void> {
    @Override
    protected final Void doRun() throws InterruptedException, SuspendExecution {
        for (;;) {
            final Msg m = receive();
            System.err.println("USER CONSUMER: " + m);
            if (EXIT.equals(m))
                return null;
        }
    }
}

Luckily every actor, no matter what it does, has the same very basic interface: an incoming message queue called mailbox. This means we can insert between two communicating actors as many middle actors, or proxies, as we want and in particular we want a sending proxy that will get messages through our middleware to the destination host, and a receiving proxy there that will grab incoming messages and will put them into the intended destination’s mailbox.

So in our main program we’ll simply provide our ProducerActor with a suitable sending proxy and we’ll let our ConsumerActor receive from a suitable receiving proxy:

final ProducerActor pa = Actor.newActor(ProducerActor.class, getSendingProxy()); // ...
final ConsumerActor ca = Actor.newActor(ConsumerActor.class);
pa.spawn();
System.err.println("USER PRODUCER started");
subscribeToReceivingProxy(ca.spawn()); // ...
System.err.println("USER CONSUMER started");
pa.join();
System.err.println("USER PRODUCER finished");
ca.join();
System.err.println("USER CONSUMER finished");

Let’s see how we can implement these proxies with Kafka first and then with ØMQ.

Kafka actor proxies

A factory of proxy actors will be tied to a specific Kafka topic: this is because a topic can be partitioned in such a way that multiple consumers can read concurrently from it. We want to be able to exploit optimally each topic’s maximum level or concurrency:

/* ... */ KafkaProxies implements AutoCloseable {
    /* ... */ KafkaProxies(String bootstrap, String topic) { /* ... */ }

    // ...
}

Of course we want to use a topic for multiple actors, so sending proxies will specify a recipient actor ID and receiving proxies will forward the message only to user actors bound to that ID:

/* ... */ <M> ActorRef<M> create(String actorID) { /* ... */ }
/* ... */ void drop(ActorRef ref) throws ExecutionException, InterruptedException { /* ... */ }
/* ... */ <M> void subscribe(ActorRef<? super M> consumer, String actorID) { /* ... */ }
/* ... */ void unsubscribe(ActorRef<?> consumer, String actorID) { /* ... */ }

Closing the AutoClosable factory will tell all proxies to terminate and will cleanup book-keeping references:

/* ... */ void close() throws Exception { /* ... */ }

The producer implementation is rather straightforward and uninteresting while there’s a bit more spice to the consumer because it will use Quasar Actors’ selective receive to retain incoming messages in its mailbox until there is at least one subscribed user actor that can consume them:

@Override
protected Void doRun() throws InterruptedException, SuspendExecution {
    //noinspection InfiniteLoopStatement
    for (;;) {
    // Try extracting from queue
    final Object msg = tryReceive((Object m) -> {
        if (EXIT.equals(m))
            return EXIT;
        if (m != null) {
            //noinspection unchecked
            final ProxiedMsg rmsg = (ProxiedMsg) m;
            final List<ActorRef> l = subscribers.get(rmsg.actorID);
            if (l != null) {
                boolean sent = false;
                for (final ActorRef r : l) {
                    //noinspection unchecked
                    r.send(rmsg.payload);
                    sent = true;
                }
                if (sent) // Someone was listening, remove from queue
                    return m;
            }
        }
        return null; // No subscribers (leave in queue) or no messages
    });
    // Something from queue
    if (msg != null) {
        if (EXIT.equals(msg)) {
            return null;
        }
        continue; // Go to next cycle -> precedence to queue
    }

    // Try receiving
    //noinspection Convert2Lambda
    final ConsumerRecords<Void, byte[]> records = call(e, () -> consumer.get().poll(100L));
    for (final ConsumerRecord<Void, byte[]> record : records) {
        final byte[] v = record.value();
        try (final ByteArrayInputStream bis = new ByteArrayInputStream(v);
             final ObjectInputStream ois = new ObjectInputStream(bis)) {

            //noinspection unchecked
            final ProxiedMsg rmsg = (ProxiedMsg) ois.readObject();
            final List<ActorRef> l = subscribers.get(rmsg.actorID);
            if (l != null && l.size() > 0) {
                for (final ActorRef r : l) {
                    //noinspection unchecked
                    r.send(rmsg.payload);
                }
            } else {
                ref().send(rmsg); // Enqueue
            }
        } catch (final IOException | ClassNotFoundException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

Since we need to process the mailbox as well, we’re polling Kafka with a small enough timeout. Also note that many actors can subscribe to the same ID and the incoming message will be broadcast to all of them. The number of receiving actor proxies (so, fibers) created per topic, as well as the number of pool threads and Kafka consumer handles (consumer is a thread-local because Kafka consumers are not thread-safe) will be equal to the number of partitions per topic: this allows the receiving throughput to be maximum.

At present this implementation uses Java serialization to convert messages to and from bytes but of course other frameworks such as Kryo can be used.

ØMQ actor proxies

The ØMQ model is fully decentralized: there aren’t brokers nor topics so we can simply equate ØMQ address/endpoint with a set of actors, without using an extra actor ID:

/* ... */ ZeroMQProxies implements AutoCloseable {
    /* ... */ ZeroMQProxies(int ioThreads) { /* ... */ }
    /* ... */ <M> ActorRef<M> to(String trgtZMQAddress) { /* ... */ }
    /* ... */ void drop(String trgtZMQAddress)
    /* ... */ void subscribe(ActorRef<? super M> consumer, String srcZMQEndpoint) { /* ... */ }
    /* ... */ void unsubscribe(ActorRef<?> consumer, String srcZMQEndpoint) { /* ... */ }
    /* ... */ void close() throws Exception { /* ... */ }
}

In this case too, and for the same reason as before, the consumer is a bit interesting minus, luckily, any issues with thread-safety because ØMQ sockets work just fine in multiple threads:

@Override
protected Void doRun() throws InterruptedException, SuspendExecution {
    try(final ZMQ.Socket src = zmq.socket(ZMQ.REP)) {
        System.err.printf("PROXY CONSUMER: binding %s\n", srcZMQEndpoint);
        Util.exec(e, () -> src.bind(srcZMQEndpoint));
        src.setReceiveTimeOut(100);
        //noinspection InfiniteLoopStatement
        for (;;) {
            // Try extracting from queue
            final Object m = tryReceive((Object o) -> {
                if (EXIT.equals(o))
                    return EXIT;
                if (o != null) {
                    //noinspection unchecked
                    final List<ActorRef> l = subscribers.get(srcZMQEndpoint);
                    if (l != null) {
                        boolean sent = false;
                        for (final ActorRef r : l) {
                            //noinspection unchecked
                            r.send(o);
                            sent = true;
                        }
                        if (sent) // Someone was listening, remove from queue
                            return o;
                    }
                }
                return null; // No subscribers (leave in queue) or no messages
            });
            // Something processable is there
            if (m != null) {
                if (EXIT.equals(m)) {
                    return null;
                }
                continue; // Go to next cycle -> precedence to queue
            }

            System.err.println("PROXY CONSUMER: receiving");
            final byte[] msg = Util.call(e, src::recv);
            if (msg != null) {
                System.err.println("PROXY CONSUMER: ACKing");
                Util.exec(e, () -> src.send(ACK));
                final Object o;
                try (final ByteArrayInputStream bis = new ByteArrayInputStream(msg);
                     final ObjectInputStream ois = new ObjectInputStream(bis)) {
                    o = ois.readObject();
                } catch (final IOException | ClassNotFoundException e) {
                    e.printStackTrace();
                    throw new RuntimeException(e);
                }
                System.err.printf("PROXY CONSUMER: distributing '%s' to %d subscribers\n", o, subscribers.size());
                //noinspection unchecked
                for (final ActorRef s : subscribers.getOrDefault(srcZMQEndpoint, (List<ActorRef>) Collections.EMPTY_LIST))
                    //noinspection unchecked
                    s.send(o);
            } else {
                System.err.println("PROXY CONSUMER: receive timeout");
            }
        }
    }
}

More features

This short writeup has hopefully given a glance of how easy it is to seamlessly interface Quasar’s Actors with messaging solutions due to their nature of straightforward sequential processes; of course it’s possible to go further, for example:

  • Actors lookup and discovery: how do we provide a global actor naming/discovery service? For example Kafka uses ZooKeeper so it’s probably worth leveraging that but ØMQ bets heavily on de-centralization and deliberately doesn’t provide a pre-packaged foundation.
  • Actors failure management: how can we support failure-management links and watches between actors that run in different nodes?
  • Messages routing: how do we dynamically adjust message flows between nodes and actors without changing the logic inside actors?
  • Actors mobility: how do we move the actors to other nodes, for example closer to their message source in order to gain performance or to a location with different security properties?
  • Scalability and fault-tolerance: how to manage the addition, removal, death and partitioning of actor nodes? Distributed IMDGs like Galaxy and broker-based solution like Kafka typically already do that but fabric-level solutions like ØMQ usually don’t.
  • Security: how do we support relevant information security properties?
  • Testing, logging, monitoring: how do we conveniently test, trace and monitor a distributed actors ensemble as a whole?

These topics are the the “hard nut” of distributed systems design and distributed actors in particular, so tackling them effectively can require substantial effort. Galaxy addresses all of them, but Quasar actors provide an SPI that covers some of the above topics and that allows for a tighter integration with distribution technologies. You might also be interested in a comparison between Akka and Quasar+Galaxy that covers many such aspects.

That’s it for now, so have fun with your distributed Quasar actors and leave a note about your journey in the Quasar-Pulsar user group!

  1. Actually it also forbids usage by any threads except the first one.

Fabio Tudone

Fabio Tudone is a software developer at Parallel Universe, tirelessly looking for ways to write better code.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button