/*
* (C) Copyright 2017 Nuxeo (http://nuxeo.com/) and others.
*
* 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.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.redis.contribs;
import static redis.clients.jedis.Protocol.Keyword.MESSAGE;
import static redis.clients.jedis.Protocol.Keyword.PMESSAGE;
import static redis.clients.jedis.Protocol.Keyword.PSUBSCRIBE;
import static redis.clients.jedis.Protocol.Keyword.PUNSUBSCRIBE;
import static redis.clients.jedis.Protocol.Keyword.SUBSCRIBE;
import static redis.clients.jedis.Protocol.Keyword.UNSUBSCRIBE;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.ecm.core.api.NuxeoException;
import org.nuxeo.ecm.core.pubsub.AbstractPubSubProvider;
import org.nuxeo.ecm.core.pubsub.PubSubProvider;
import org.nuxeo.ecm.core.redis.RedisAdmin;
import org.nuxeo.ecm.core.redis.RedisExecutor;
import org.nuxeo.runtime.api.Framework;
import redis.clients.jedis.Client;
import redis.clients.jedis.JedisPubSub;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.util.SafeEncoder;
/**
* Redis implementation of {@link PubSubProvider}.
*
* @since 9.1
*/
public class RedisPubSubProvider extends AbstractPubSubProvider {
// package-private to avoid synthetic accessor for nested class
static final Log log = LogFactory.getLog(RedisPubSubProvider.class);
/** Maximum delay to wait for a channel subscription on startup. */
public static final long TIMEOUT_SUBSCRIBE_SECONDS = 5;
protected String namespace;
protected Dispatcher dispatcher;
protected Thread thread;
@Override
public void initialize(Map<String, List<BiConsumer<String, byte[]>>> subscribers) {
super.initialize(subscribers);
log.debug("Initializing");
namespace = Framework.getService(RedisAdmin.class).namespace();
dispatcher = new Dispatcher(namespace + "*");
thread = new Thread(dispatcher::run, "Nuxeo-PubSub-Redis");
thread.setUncaughtExceptionHandler((t, e) -> log.error("Uncaught error on thread " + t.getName(), e));
thread.setPriority(Thread.NORM_PRIORITY);
thread.setDaemon(true);
thread.start();
if (!dispatcher.awaitSubscribed(TIMEOUT_SUBSCRIBE_SECONDS, TimeUnit.SECONDS)) {
thread.interrupt();
throw new NuxeoException(
"Failed to subscribe to Redis pubsub after " + TIMEOUT_SUBSCRIBE_SECONDS + "s");
}
log.debug("Initialized");
}
@Override
public void close() {
log.debug("Closing");
if (dispatcher != null) {
thread.interrupt();
thread = null;
dispatcher.close();
dispatcher = null;
}
log.debug("Closed");
}
/**
* Subscribes to the provided Redis channel pattern and dispatches received messages. Method {@code #run} must be
* called in a new thread.
*/
public class Dispatcher extends JedisPubSub {
// we look this up during construction in the main thread,
// because service lookup is unavailable from alternative threads during startup
protected RedisExecutor redisExecutor;
protected final String pattern;
protected final CountDownLatch subscribedLatch;
protected volatile boolean stop;
public Dispatcher(String pattern) {
redisExecutor = Framework.getService(RedisExecutor.class);
this.pattern = pattern;
this.subscribedLatch = new CountDownLatch(1);
}
/**
* To be called from the main thread to wait for subscription to be effective.
*/
public boolean awaitSubscribed(long timeout, TimeUnit unit) {
try {
return subscribedLatch.await(timeout, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new NuxeoException(e);
}
}
/**
* To be called from a new thread to do the actual Redis subscription and to dispatch messages.
*/
public void run() {
log.debug("Subscribing to: " + pattern);
// we can't do service lookup during startup here because we're in a separate thread
RedisExecutor redisExecutor = this.redisExecutor;
this.redisExecutor = null;
redisExecutor.psubscribe(this, pattern);
}
/**
* To be called from the main thread to stop the subscription.
*/
public void close() {
stop = true;
// send an empty message so that the dispatcher thread can be woken up and stop
publish("", new byte[0]);
}
@Override
public void onPSubscribe(String pattern, int subscribedChannels) {
subscribedLatch.countDown();
if (log.isDebugEnabled()) {
log.debug("Subscribed to: " + pattern);
}
}
public void onMessage(String channel, byte[] message) {
if (message == null) {
message = new byte[0];
}
if (log.isTraceEnabled()) {
log.trace("Message received from channel: " + channel + " (" + message.length + " bytes)");
}
String topic = channel.substring(namespace.length());
localPublish(topic, message);
}
public void onPMessage(String pattern, String channel, byte[] message) {
onMessage(channel, message);
}
@Override
public void proceed(Client client, String... channels) {
client.subscribe(channels);
flush(client);
processBinary(client);
}
@Override
public void proceedWithPatterns(Client client, String... patterns) {
client.psubscribe(patterns);
flush(client);
processBinary(client);
}
// stupid Jedis has a protected flush method
protected void flush(Client client) {
try {
Method m = redis.clients.jedis.Connection.class.getDeclaredMethod("flush");
m.setAccessible(true);
m.invoke(client);
} catch (ReflectiveOperationException e) {
throw new NuxeoException(e);
}
}
// patched process() to pass the raw binary message to onMessage and onPMessage
protected void processBinary(Client client) {
for (;;) {
List<Object> reply = client.getRawObjectMultiBulkReply();
if (stop) {
return;
}
Object type = reply.get(0);
if (!(type instanceof byte[])) {
throw new JedisException("Unknown message type: " + type);
}
byte[] btype = (byte[]) type;
if (Arrays.equals(MESSAGE.raw, btype)) {
byte[] bchannel = (byte[]) reply.get(1);
byte[] bmesg = (byte[]) reply.get(2);
onMessage(toString(bchannel), bmesg);
} else if (Arrays.equals(PMESSAGE.raw, btype)) {
byte[] bpattern = (byte[]) reply.get(1);
byte[] bchannel = (byte[]) reply.get(2);
byte[] bmesg = (byte[]) reply.get(3);
onPMessage(toString(bpattern), toString(bchannel), bmesg);
} else if (Arrays.equals(SUBSCRIBE.raw, btype)) {
byte[] bchannel = (byte[]) reply.get(1);
onSubscribe(toString(bchannel), 0);
} else if (Arrays.equals(PSUBSCRIBE.raw, btype)) {
byte[] bpattern = (byte[]) reply.get(1);
onPSubscribe(toString(bpattern), 0);
} else if (Arrays.equals(UNSUBSCRIBE.raw, btype)) {
byte[] bchannel = (byte[]) reply.get(1);
onUnsubscribe(toString(bchannel), 0);
} else if (Arrays.equals(PUNSUBSCRIBE.raw, btype)) {
byte[] bpattern = (byte[]) reply.get(1);
onPUnsubscribe(toString(bpattern), 0);
} else {
throw new JedisException("Unknown message: " + toString(btype));
}
}
}
protected String toString(byte[] bytes) {
return bytes == null ? null : SafeEncoder.encode(bytes);
}
}
// ===== PubSubService =====
@Override
public void publish(String topic, byte[] message) {
String channel = namespace + topic;
byte[] bchannel = SafeEncoder.encode(channel);
RedisExecutor redisExecutor = Framework.getService(RedisExecutor.class);
if (redisExecutor != null) {
redisExecutor.execute(jedis -> jedis.publish(bchannel, message));
}
}
}