From fcf14ac2227613d21ba7d237122e87a75c0abb54 Mon Sep 17 00:00:00 2001 From: rob bygrave Date: Thu, 17 Dec 2020 11:56:34 +1300 Subject: [PATCH] Add ebean-redis as module --- ebean-redis/pom.xml | 95 ++++ .../main/java/io/ebean/redis/DuelCache.java | 161 ++++++ .../src/main/java/io/ebean/redis/ModId.java | 49 ++ .../io/ebean/redis/NearCacheInvalidate.java | 24 + .../java/io/ebean/redis/NearCacheNotify.java | 24 + .../main/java/io/ebean/redis/RedisCache.java | 268 +++++++++ .../io/ebean/redis/RedisCacheFactory.java | 525 ++++++++++++++++++ .../java/io/ebean/redis/RedisCachePlugin.java | 17 + .../main/java/io/ebean/redis/RedisConfig.java | 179 ++++++ .../java/io/ebean/redis/encode/Encode.java | 8 + .../io/ebean/redis/encode/EncodeBeanData.java | 47 ++ .../ebean/redis/encode/EncodeManyIdsData.java | 47 ++ .../ebean/redis/encode/EncodePrefixKey.java | 41 ++ .../redis/encode/EncodeSerializable.java | 37 ++ .../java/io/ebean/redis/encode/PrefixKey.java | 22 + .../io/ebean/redis/topic/DaemonTopic.java | 21 + .../ebean/redis/topic/DaemonTopicRunner.java | 95 ++++ .../services/io.ebean.cache.ServerCachePlugin | 1 + .../java/io/ebean/redis/RedisConfigTest.java | 46 ++ .../src/test/java/org/domain/EBase.java | 58 ++ .../src/test/java/org/domain/Person.java | 69 +++ .../java/org/integration/ClusterTest.java | 123 ++++ .../java/org/integration/IntegrationTest.java | 107 ++++ .../src/test/resources/application-test.yaml | 9 + .../src/test/resources/logback-test.xml | 29 + pom.xml | 1 + 26 files changed, 2103 insertions(+) create mode 100644 ebean-redis/pom.xml create mode 100644 ebean-redis/src/main/java/io/ebean/redis/DuelCache.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/ModId.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/NearCacheInvalidate.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/NearCacheNotify.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/RedisCache.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/RedisCacheFactory.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/RedisCachePlugin.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/RedisConfig.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/Encode.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/EncodeBeanData.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/EncodeManyIdsData.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/EncodePrefixKey.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/EncodeSerializable.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/encode/PrefixKey.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopic.java create mode 100644 ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopicRunner.java create mode 100644 ebean-redis/src/main/resources/META-INF/services/io.ebean.cache.ServerCachePlugin create mode 100644 ebean-redis/src/test/java/io/ebean/redis/RedisConfigTest.java create mode 100644 ebean-redis/src/test/java/org/domain/EBase.java create mode 100644 ebean-redis/src/test/java/org/domain/Person.java create mode 100644 ebean-redis/src/test/java/org/integration/ClusterTest.java create mode 100644 ebean-redis/src/test/java/org/integration/IntegrationTest.java create mode 100644 ebean-redis/src/test/resources/application-test.yaml create mode 100644 ebean-redis/src/test/resources/logback-test.xml diff --git a/ebean-redis/pom.xml b/ebean-redis/pom.xml new file mode 100644 index 000000000..7c6578935 --- /dev/null +++ b/ebean-redis/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + ebean-parent + io.ebean + 12.6.3-SNAPSHOT + + + ebean-redis + ebean test + Testing support for Ebean + + + + + + redis.clients + jedis + 3.4.0 + + + + io.ebean + ebean-api + 12.6.3-SNAPSHOT + provided + + + + io.ebean + ebean-core + 12.6.3-SNAPSHOT + provided + + + + io.ebean + ebean-querybean + 12.6.3-SNAPSHOT + test + + + + io.ebean + querybean-generator + 12.6.3-SNAPSHOT + test + + + + io.ebean + ebean-test + 12.6.3-SNAPSHOT + test + + + + org.avaje.composite + junit + 1.1 + test + + + + org.avaje.composite + logback + 1.1 + test + + + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.18 + true + + + io.ebean.tile:enhancement:12.6.1 + + + + + + + + + diff --git a/ebean-redis/src/main/java/io/ebean/redis/DuelCache.java b/ebean-redis/src/main/java/io/ebean/redis/DuelCache.java new file mode 100644 index 000000000..80025e7cc --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/DuelCache.java @@ -0,0 +1,161 @@ +package io.ebean.redis; + +import io.ebean.cache.ServerCache; +import io.ebean.cache.ServerCacheStatistics; +import io.ebean.meta.MetricVisitor; +import io.ebeaninternal.server.cache.DefaultServerCache; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class DuelCache implements ServerCache, NearCacheInvalidate { + + private final DefaultServerCache near; + private final RedisCache remote; + private final NearCacheNotify cacheNotify; + private final String cacheKey; + + public DuelCache(DefaultServerCache near, RedisCache remote, String cacheKey, NearCacheNotify cacheNotify) { + this.near = near; + this.remote = remote; + this.cacheKey = cacheKey; + this.cacheNotify = cacheNotify; + } + + @Override + public void visit(MetricVisitor visitor) { + near.visit(visitor); + remote.visit(visitor); + } + + @Override + public void invalidateKeys(Set keySet) { + near.removeAll(keySet); + } + + @Override + public void invalidateKey(Object id) { + near.remove(id); + } + + @Override + public void invalidateClear() { + near.clear(); + } + + @Override + public Map getAll(Set keys) { + + Map resultMap = near.getAll(keys); + Set localKeys = resultMap.keySet(); + + Set remainingKeys = new HashSet<>(); + for (Object key : keys) { + if (!localKeys.contains(key)) { + remainingKeys.add(key); + } + } + if (!remainingKeys.isEmpty()) { + // fetch missing ones from remote cache and merge results + Map remoteMap = remote.getAll(remainingKeys); + if (!remoteMap.isEmpty()) { + near.putAll(remoteMap); + resultMap.putAll(remoteMap); + } + } + + return resultMap; + } + + @Override + public Object get(Object id) { + Object val = near.get(id); + if (val != null) { + return val; + } + Object remoteVal = remote.get(id); + if (remoteVal != null) { + near.put(id, remoteVal); + } + return remoteVal; + } + + @Override + public void putAll(Map keyValues) { + near.putAll(keyValues); + remote.putAll(keyValues); + cacheNotify.invalidateKeys(cacheKey, keyValues.keySet()); + } + + @Override + public void put(Object id, Object value) { + near.put(id, value); + remote.put(id, value); + cacheNotify.invalidateKey(cacheKey, id); + } + + @Override + public void removeAll(Set keys) { + near.removeAll(keys); + remote.removeAll(keys); + cacheNotify.invalidateKeys(cacheKey, keys); + } + + @Override + public void remove(Object id) { + near.remove(id); + remote.remove(id); + cacheNotify.invalidateKey(cacheKey, id); + } + + @Override + public void clear() { + near.clear(); + remote.clear(); + cacheNotify.invalidateClear(cacheKey); + } + + /** + * Return the near cache hit count. + */ + public long getNearHitCount() { + return near.getHitCount(); + } + + /** + * Return the near cache miss count. + */ + public long getNearMissCount() { + return near.getMissCount(); + } + + /** + * Return the redis cache hit count. + */ + public long getRemoteHitCount() { + return remote.getHitCount(); + } + + /** + * Return the redis cache miss count. + */ + public long getRemoteMissCount() { + return remote.getMissCount(); + } + + @Override + public int size() { + return 0; + } + + @Override + public int getHitRatio() { + return 0; + } + + @Override + public ServerCacheStatistics getStatistics(boolean reset) { + return null; + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/ModId.java b/ebean-redis/src/main/java/io/ebean/redis/ModId.java new file mode 100644 index 000000000..f063dea0c --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/ModId.java @@ -0,0 +1,49 @@ +package io.ebean.redis; + +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Provides a modified base64 encoded UUID and shorter 12 character random unique value. + *

+ *

newId()

+ *

+ * It produces a 22 character string that is a base64 encoded UUID with the + + * and / characters replaced with - and _ so as to be URL safe without requiring + * encoding. + *

+ *

newShortId()

+ *

+ * It produces a 12 character string that base64 encoded random number (72 bit). + *

+ *

+ * Note that this now internally uses java.util.Base64 to encode the values. + *

+ */ +public class ModId { + + private static final SecureRandom shortIdSecureRandom = new SecureRandom(); + + private static final Base64.Encoder urlEncoder = Base64.getUrlEncoder(); + + /** + * Return a 12 character string using a 72 bit randomly generated ID encoded + * in modified base64. + *

+ * A UUID is 128 bits and this is 72 bits so quite a bit smaller but still + * very random with one in 4.7 * 10^21 chance of a collision. + *

+ */ + public static String id() { + + // Random 72 bits + byte[] randomBytes = new byte[9]; + shortIdSecureRandom.nextBytes(randomBytes); + return encode64(randomBytes); + } + + private static String encode64(byte[] bytes) { + return urlEncoder.encodeToString(bytes); + } + +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/NearCacheInvalidate.java b/ebean-redis/src/main/java/io/ebean/redis/NearCacheInvalidate.java new file mode 100644 index 000000000..88a19260d --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/NearCacheInvalidate.java @@ -0,0 +1,24 @@ +package io.ebean.redis; + +import java.util.Set; + +/** + * Near cache invalidation. + */ +public interface NearCacheInvalidate { + + /** + * Invalidate from near cache the given keys. + */ + void invalidateKeys(Set keySet); + + /** + * Invalidate from near cache the given key. + */ + void invalidateKey(Object id); + + /** + * Clear the near cache. + */ + void invalidateClear(); +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/NearCacheNotify.java b/ebean-redis/src/main/java/io/ebean/redis/NearCacheNotify.java new file mode 100644 index 000000000..24e078452 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/NearCacheNotify.java @@ -0,0 +1,24 @@ +package io.ebean.redis; + +import java.util.Set; + +/** + * Notify other cluster members to invalidate parts of their near cache. + */ +public interface NearCacheNotify { + + /** + * Invalidate the given keys. + */ + void invalidateKeys(String cacheKey, Set keySet); + + /** + * Invalidate a single key. + */ + void invalidateKey(String cacheKey, Object id); + + /** + * Clear a near cache. + */ + void invalidateClear(String cacheKey); +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/RedisCache.java b/ebean-redis/src/main/java/io/ebean/redis/RedisCache.java new file mode 100644 index 000000000..39e670a7f --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/RedisCache.java @@ -0,0 +1,268 @@ +package io.ebean.redis; + +import io.ebean.cache.ServerCache; +import io.ebean.cache.ServerCacheConfig; +import io.ebean.cache.ServerCacheStatistics; +import io.ebean.meta.MetricVisitor; +import io.ebean.metric.CountMetric; +import io.ebean.metric.MetricFactory; +import io.ebean.metric.TimedMetric; +import io.ebean.redis.encode.Encode; +import io.ebean.redis.encode.EncodePrefixKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.ScanParams; +import redis.clients.jedis.ScanResult; +import redis.clients.jedis.util.SafeEncoder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +class RedisCache implements ServerCache { + + private static final Logger log = LoggerFactory.getLogger(RedisCache.class); + + private static final String CURSOR_0 = "0"; + private static final byte[] CURSOR_0_BYTES = SafeEncoder.encode(CURSOR_0); + + private final JedisPool jedisPool; + private final String cacheKey; + private final Encode keyEncode; + private final Encode valueEncode; + + private final TimedMetric metricGet; + private final TimedMetric metricGetAll; + private final TimedMetric metricPut; + private final TimedMetric metricPutAll; + private final TimedMetric metricRemove; + private final TimedMetric metricRemoveAll; + private final TimedMetric metricClear; + private final CountMetric hitCount; + private final CountMetric missCount; + + RedisCache(JedisPool jedisPool, ServerCacheConfig config, Encode valueEncode) { + + this.jedisPool = jedisPool; + this.cacheKey = config.getCacheKey(); + this.keyEncode = new EncodePrefixKey(config.getCacheKey()); + this.valueEncode = valueEncode; + + String pre = "l2r."; + String shortName = config.getShortName(); + MetricFactory factory = MetricFactory.get(); + + hitCount = factory.createCountMetric(pre + shortName + ".hit"); + missCount = factory.createCountMetric(pre + shortName + ".miss"); + metricGet = factory.createTimedMetric(pre + shortName + ".get"); + metricGetAll = factory.createTimedMetric(pre + shortName + ".getMany"); + metricPut = factory.createTimedMetric(pre + shortName + ".put"); + metricPutAll = factory.createTimedMetric(pre + shortName + ".putMany"); + metricRemove = factory.createTimedMetric(pre + shortName + ".remove"); + metricRemoveAll = factory.createTimedMetric(pre + shortName + ".removeMany"); + metricClear = factory.createTimedMetric(pre + shortName + ".clear"); + } + + @Override + public void visit(MetricVisitor visitor) { + hitCount.visit(visitor); + missCount.visit(visitor); + metricGet.visit(visitor); + metricGetAll.visit(visitor); + metricPut.visit(visitor); + metricPutAll.visit(visitor); + metricRemove.visit(visitor); + metricRemoveAll.visit(visitor); + metricClear.visit(visitor); + } + + private byte[] key(Object id) { + return keyEncode.encode(id); + } + + private byte[] value(Object data) { + if (data == null) { + return null; + } + return valueEncode.encode(data); + } + + private Object valueDecode(byte[] data) { + try { + if (data == null) { + return null; + } + return valueEncode.decode(data); + } catch (Exception e) { + log.error("Error decoding data, treated as cache miss", e); + return null; + } + } + + @Override + public Map getAll(Set keys) { + + long start = System.nanoTime(); + Map map = new LinkedHashMap<>(); + + List keyList = new ArrayList<>(keys); + try (Jedis resource = jedisPool.getResource()) { + List valsAsBytes = resource.mget(keysAsBytes(keyList)); + for (int i = 0; i < keyList.size(); i++) { + Object val = valueDecode(valsAsBytes.get(i)); + if (val != null) { + map.put(keyList.get(i), val); + } + } + int hits = map.size(); + int miss = keys.size() - hits; + if (hits > 0) { + hitCount.add(hits); + } + if (miss > 0) { + missCount.add(miss); + } + metricGetAll.addSinceNanos(start); + return map; + } + } + + @Override + public Object get(Object id) { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + Object val = valueDecode(resource.get(key(id))); + if (val != null) { + hitCount.increment(); + } else { + missCount.increment(); + } + metricGet.addSinceNanos(start); + return val; + } + } + + + @Override + public void put(Object id, Object value) { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.set(key(id), value(value)); + metricPut.addSinceNanos(start); + } + } + + @Override + public void putAll(Map keyValues) { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + byte[][] raw = new byte[keyValues.size() * 2][]; + int pos = 0; + for (Map.Entry entry : keyValues.entrySet()) { + raw[pos++] = key(entry.getKey()); + raw[pos++] = value(entry.getValue()); + } + resource.mset(raw); + metricPutAll.addSinceNanos(start); + } + } + + @Override + public void remove(Object id) { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.del(key(id)); + metricRemove.addSinceNanos(start); + } + } + + @Override + public void removeAll(Set keys) { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.del(keysAsBytes(keys)); + metricRemoveAll.addSinceNanos(start); + } + } + + private byte[][] keysAsBytes(Collection keys) { + byte[][] raw = new byte[keys.size()][]; + int pos = 0; + for (Object id : keys) { + raw[pos++] = key(id); + } + return raw; + } + + @Override + public void clear() { + long start = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + + ScanParams params = new ScanParams(); + params.match(cacheKey + ":*"); + + String next; + byte[] nextCursor = CURSOR_0_BYTES; + do { + ScanResult scanResult = resource.scan(nextCursor, params); + List keys = scanResult.getResult(); + nextCursor = scanResult.getCursorAsBytes(); + + if (!keys.isEmpty()) { + byte[][] raw = new byte[keys.size()][]; + for (int i = 0; i < keys.size(); i++) { + raw[i] = keys.get(i); + } + resource.del(raw); + } + + next = SafeEncoder.encode(nextCursor); + } while (!next.equals("0")); + + metricClear.addSinceNanos(start); + } + } + + /** + * Return the count of get hits. + */ + public long getHitCount() { + return hitCount.get(false); + } + + /** + * Return the count of get misses. + */ + public long getMissCount() { + return missCount.get(false); + } + + @Override + public int size() { + return 0; + } + + @Override + public int getHitRatio() { + return 0; + } + + @Override + public ServerCacheStatistics getStatistics(boolean reset) { + + ServerCacheStatistics cacheStats = new ServerCacheStatistics(); + cacheStats.setCacheName(cacheKey); + cacheStats.setHitCount(hitCount.get(reset)); + cacheStats.setMissCount(missCount.get(reset)); + cacheStats.setPutCount(metricPut.collect(reset).getCount()); + cacheStats.setRemoveCount(metricRemove.collect(reset).getCount()); + cacheStats.setClearCount(metricClear.collect(reset).getCount()); + return cacheStats; + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/RedisCacheFactory.java b/ebean-redis/src/main/java/io/ebean/redis/RedisCacheFactory.java new file mode 100644 index 000000000..93ed840e1 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/RedisCacheFactory.java @@ -0,0 +1,525 @@ +package io.ebean.redis; + +import io.ebean.BackgroundExecutor; +import io.ebean.cache.ServerCache; +import io.ebean.cache.ServerCacheConfig; +import io.ebean.cache.ServerCacheFactory; +import io.ebean.cache.ServerCacheNotification; +import io.ebean.cache.ServerCacheNotify; +import io.ebean.config.DatabaseConfig; +import io.ebean.meta.MetricVisitor; +import io.ebean.metric.MetricFactory; +import io.ebean.metric.TimedMetric; +import io.ebean.redis.encode.EncodeBeanData; +import io.ebean.redis.encode.EncodeManyIdsData; +import io.ebean.redis.encode.EncodeSerializable; +import io.ebean.redis.topic.DaemonTopic; +import io.ebean.redis.topic.DaemonTopicRunner; +import io.ebeaninternal.server.cache.DefaultServerCache; +import io.ebeaninternal.server.cache.DefaultServerCacheConfig; +import io.ebeaninternal.server.cache.DefaultServerQueryCache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.BinaryJedisPubSub; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.util.SafeEncoder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static java.util.Arrays.asList; + +class RedisCacheFactory implements ServerCacheFactory { + + private static final Logger log = LoggerFactory.getLogger(RedisCacheFactory.class); + + private static final Logger queryLogger = LoggerFactory.getLogger("io.ebean.cache.QUERY"); + + private static final Logger logger = LoggerFactory.getLogger("io.ebean.cache.CACHE"); + + private static final Logger tableModLogger = LoggerFactory.getLogger("io.ebean.cache.TABLEMODS"); + + private static final int MSG_NEARCACHE_CLEAR = 1; + private static final int MSG_NEARCACHE_KEYS = 2; + private static final int MSG_NEARCACHE_KEY = 3; + + /** + * Channel for standard L2 cache messages. + */ + private static final String CHANNEL_L2 = "ebean.l2cache"; + + /** + * Channel specifically for near cache invalidation messages. + */ + private static final String CHANNEL_NEAR = "ebean.l2near"; + + private static final byte[] CHANNEL_L2_BYTES = SafeEncoder.encode(CHANNEL_L2); + private static final byte[] CHANNEL_NEAR_BYTES = SafeEncoder.encode(CHANNEL_NEAR); + + private final ConcurrentHashMap queryCaches = new ConcurrentHashMap<>(); + + private final Map nearCacheMap = new ConcurrentHashMap<>(); + + private final EncodeManyIdsData encodeManyIdsData = new EncodeManyIdsData(); + private final EncodeBeanData encodeBeanData = new EncodeBeanData(); + private final EncodeSerializable encodeSerializable = new EncodeSerializable(); + + private final BackgroundExecutor executor; + + private final JedisPool jedisPool; + + private final DaemonTopicRunner daemonTopicRunner; + + private final NearCacheNotify nearCacheNotify; + + private final TimedMetric metricOutNearCache; + private final TimedMetric metricOutTableMod; + private final TimedMetric metricOutQueryCache; + private final TimedMetric metricInNearCache; + private final TimedMetric metricInTableMod; + private final TimedMetric metricInQueryCache; + + private final String serverId = ModId.id(); + + private ServerCacheNotify listener; + + RedisCacheFactory(DatabaseConfig config, BackgroundExecutor executor) { + this.executor = executor; + this.nearCacheNotify = new DNearCacheNotify(); + MetricFactory factory = MetricFactory.get(); + this.metricOutTableMod = factory.createTimedMetric("l2a.outTableMod"); + this.metricOutQueryCache = factory.createTimedMetric("l2a.outQueryCache"); + this.metricOutNearCache = factory.createTimedMetric("l2a.outNearKeys"); + this.metricInTableMod = factory.createTimedMetric("l2a.inTableMod"); + this.metricInQueryCache = factory.createTimedMetric("l2a.inQueryCache"); + this.metricInNearCache = factory.createTimedMetric("l2a.inNearKeys"); + if (config.isDisableL2Cache()) { + this.jedisPool = null; + this.daemonTopicRunner = null; + } else { + this.jedisPool = getJedisPool(config); + this.daemonTopicRunner = new DaemonTopicRunner(jedisPool, new CacheDaemonTopic()); + daemonTopicRunner.run(); + } + } + + /** + * Return the JedisPool to use (only 1 at this stage). + */ + private JedisPool getJedisPool(DatabaseConfig config) { + JedisPool jedisPool = config.getServiceObject(JedisPool.class); + if (jedisPool != null) { + return jedisPool; + } + RedisConfig redisConfig = config.getServiceObject(RedisConfig.class); + if (redisConfig == null) { + redisConfig = new RedisConfig(); + } + redisConfig.loadProperties(config.getProperties()); + log.info("using l2cache redis host {}:{}", redisConfig.getServer(), redisConfig.getPort()); + return redisConfig.createPool(); + } + + @Override + public void visit(MetricVisitor visitor) { + metricOutQueryCache.visit(visitor); + metricOutTableMod.visit(visitor); + metricOutNearCache.visit(visitor); + metricInTableMod.visit(visitor); + metricInQueryCache.visit(visitor); + metricInNearCache.visit(visitor); + } + + @Override + public ServerCache createCache(ServerCacheConfig config) { + if (config.isQueryCache()) { + return createQueryCache(config); + } + return createNormalCache(config); + } + + private ServerCache createNormalCache(ServerCacheConfig config) { + + RedisCache redisCache = createRedisCache(config); + boolean nearCache = config.getCacheOptions().isNearCache(); + if (!nearCache) { + return redisCache; + } + + String cacheKey = config.getCacheKey(); + + DefaultServerCache near = new DefaultServerCache(new DefaultServerCacheConfig(config)); + near.periodicTrim(executor); + DuelCache duelCache = new DuelCache(near, redisCache, cacheKey, nearCacheNotify); + nearCacheMap.put(cacheKey, duelCache); + + return duelCache; + } + + private RedisCache createRedisCache(ServerCacheConfig config) { + + switch (config.getType()) { + case NATURAL_KEY: + return new RedisCache(jedisPool, config, encodeSerializable); + case BEAN: + return new RedisCache(jedisPool, config, encodeBeanData); + case COLLECTION_IDS: + return new RedisCache(jedisPool, config, encodeManyIdsData); + default: + throw new IllegalArgumentException("Unexpected cache type? " + config.getType()); + } + } + + private ServerCache createQueryCache(ServerCacheConfig config) { + synchronized (this) { + RQueryCache cache = queryCaches.get(config.getCacheKey()); + if (cache == null) { + logger.debug("create query cache [{}]", config.getCacheKey()); + cache = new RQueryCache(new DefaultServerCacheConfig(config)); + cache.periodicTrim(executor); + queryCaches.put(config.getCacheKey(), cache); + } + return cache; + } + } + + @Override + public ServerCacheNotify createCacheNotify(ServerCacheNotify listener) { + this.listener = listener; + return new RServerCacheNotify(); + } + + private void sendQueryCacheInvalidation(String name) { + long nanos = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.publish(CHANNEL_L2, serverId + ":queryCache:" + name); + } finally { + metricOutQueryCache.addSinceNanos(nanos); + } + } + + private void sendTableMod(String formattedMsg) { + long nanos = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.publish(CHANNEL_L2, serverId + ":tableMod:" + formattedMsg); + } finally { + metricOutTableMod.addSinceNanos(nanos); + } + } + + /** + * Query cache implementation using Redis channel for message notifications. + */ + private class RQueryCache extends DefaultServerQueryCache { + + RQueryCache(DefaultServerCacheConfig config) { + super(config); + } + + @Override + public void clear() { + super.clear(); + sendQueryCacheInvalidation(name); + } + + /** + * Process the invalidation message coming from the cluster. + */ + private void invalidate() { + queryLogger.debug(" CLEAR {}(*) - cluster invalidate", name); + super.clear(); + } + } + + /** + * Publish table modifications using Redis channel (to other cluster members) + */ + private class RServerCacheNotify implements ServerCacheNotify { + + @Override + public void notify(ServerCacheNotification tableModifications) { + + Set dependentTables = tableModifications.getDependentTables(); + if (dependentTables != null && !dependentTables.isEmpty()) { + + StringBuilder msg = new StringBuilder(50); + for (String table : dependentTables) { + msg.append(table).append(","); + } + + String formattedMsg = msg.toString(); + if (tableModLogger.isDebugEnabled()) { + tableModLogger.debug("Publish TableMods - {}", formattedMsg); + } + sendTableMod(formattedMsg); + } + } + } + + /** + * Clear the query cache if we have it. + */ + private void queryCacheInvalidate(String key) { + long nanos = System.nanoTime(); + try { + RQueryCache queryCache = queryCaches.get(key); + if (queryCache != null) { + queryCache.invalidate(); + } + } finally { + metricInQueryCache.addSinceNanos(nanos); + } + } + + /** + * Process a remote dependent table modify event. + */ + private void processTableNotify(String rawMessage) { + long nanos = System.nanoTime(); + try { + if (logger.isDebugEnabled()) { + logger.debug("processTableNotify {}", rawMessage); + } + Set tables = new HashSet<>(asList(rawMessage.split(","))); + listener.notify(new ServerCacheNotification(tables)); + } finally { + metricInTableMod.addSinceNanos(nanos); + } + } + + /** + * Near cache notification using a specific Redis channel (CHANNEL_NEAR). + */ + private class DNearCacheNotify implements NearCacheNotify { + + @Override + public void invalidateKeys(String cacheKey, Set keySet) { + try { + sendMessage(messageInvalidateKeys(cacheKey, keySet)); + } catch (IOException e) { + logger.error("failed to transmit invalidateKeys() message", e); + } + } + + @Override + public void invalidateKey(String cacheKey, Object id) { + try { + sendMessage(messageInvalidateKey(cacheKey, id)); + } catch (IOException e) { + logger.error("failed to transmit invalidateKeys() message", e); + } + } + + @Override + public void invalidateClear(String cacheKey) { + try { + sendMessage(messageInvalidateClear(cacheKey)); + } catch (IOException e) { + logger.error("failed to transmit invalidateKeys() message", e); + } + } + + private void sendMessage(byte[] message) { + long nanos = System.nanoTime(); + try (Jedis resource = jedisPool.getResource()) { + resource.publish(CHANNEL_NEAR_BYTES, message); + } finally { + metricOutNearCache.addSinceNanos(nanos); + } + } + + private byte[] messageInvalidateKeys(String cacheKey, Set keySet) throws IOException { + ByteArrayOutputStream ba = new ByteArrayOutputStream(100); + ObjectOutputStream os = new ObjectOutputStream(ba); + os.writeUTF(serverId); + os.writeInt(MSG_NEARCACHE_KEYS); + os.writeUTF(cacheKey); + os.writeInt(keySet.size()); + for (Object key : keySet) { + os.writeObject(key); + } + os.flush(); + os.close(); + return ba.toByteArray(); + } + + private byte[] messageInvalidateKey(String cacheKey, Object id) throws IOException { + ByteArrayOutputStream ba = new ByteArrayOutputStream(100); + ObjectOutputStream os = new ObjectOutputStream(ba); + os.writeUTF(serverId); + os.writeInt(MSG_NEARCACHE_KEY); + os.writeUTF(cacheKey); + os.writeObject(id); + os.flush(); + os.close(); + return ba.toByteArray(); + } + + private byte[] messageInvalidateClear(String cacheKey) throws IOException { + ByteArrayOutputStream ba = new ByteArrayOutputStream(100); + ObjectOutputStream os = new ObjectOutputStream(ba); + os.writeUTF(serverId); + os.writeInt(MSG_NEARCACHE_CLEAR); + os.writeUTF(cacheKey); + os.flush(); + os.close(); + return ba.toByteArray(); + } + } + + /** + * Redis channel listener that supports reconnection etc. + */ + private class CacheDaemonTopic implements DaemonTopic { + + @Override + public void subscribe(Jedis jedis) { + jedis.subscribe(new ChannelSubscriber(), CHANNEL_L2_BYTES, CHANNEL_NEAR_BYTES); + } + + @Override + public void notifyConnected() { + logger.info("Established connection to Redis"); + } + + /** + * Handles updates to the features (via redis topic notifications). + */ + private class ChannelSubscriber extends BinaryJedisPubSub { + + @Override + public void onMessage(byte[] channel, byte[] message) { + String channelName = SafeEncoder.encode(channel); + if (channelName.equals(CHANNEL_L2)) { + processL2Message(SafeEncoder.encode(message)); + } else { + processNearCacheMessage(message); + } + } + + private void processNearCacheMessage(byte[] message) { + long nanos = System.nanoTime(); + int msgType = 0; + String cacheKey = null; + try { + ObjectInputStream oi = new ObjectInputStream(new ByteArrayInputStream(message)); + String sourceServerId = oi.readUTF(); + if (sourceServerId.equals(serverId)) { + // ignore this message as we are the server that sent it + return; + } + msgType = oi.readInt(); + cacheKey = oi.readUTF(); + if (logger.isDebugEnabled()) { + logger.debug("processNearCacheMessage serverId:{} type:{} cacheKey:{}", sourceServerId, msgType, cacheKey); + } + + switch (msgType) { + case MSG_NEARCACHE_CLEAR: + nearCacheInvalidateClear(cacheKey); + break; + + case MSG_NEARCACHE_KEY: + Object key = oi.readObject(); + nearCacheInvalidateKey(cacheKey, key); + break; + + case MSG_NEARCACHE_KEYS: + int count = oi.readInt(); + Set keys = new LinkedHashSet<>(); + for (int i = 0; i < count; i++) { + keys.add(oi.readObject()); + } + nearCacheInvalidateKeys(cacheKey, keys); + break; + + default: + throw new IllegalStateException("Unexpected message type ? " + msgType); + } + + } catch (IOException | ClassNotFoundException e) { + logger.error("failed to decode near cache message [" + SafeEncoder.encode(message) + "] for cache:" + cacheKey, e); + if (cacheKey != null) { + nearCacheInvalidateClear(cacheKey); + } + } finally { + if (msgType != 0) { + metricInNearCache.addSinceNanos(nanos); + } + } + } + + private void processL2Message(String message) { + try { + String[] split = message.split(":"); + if (serverId.equals(split[0])) { + // ignore this message as we are the server that sent it + return; + } + switch (split[1]) { + case "tableMod": + processTableNotify(split[2]); + break; + case "queryCache": + queryCacheInvalidate(split[2]); + break; + default: + logger.error("Unknown L2 message type[{}] on redis channel - message[{}] ", split[0], message); + } + } catch (Exception e) { + logger.error("Error handling L2 message[" + message + "]", e); + } + } + } + } + + /** + * Invalidate key for a local near cache. + */ + private void nearCacheInvalidateKey(String cacheKey, Object key) { + NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey); + if (invalidate == null) { + warnNearCacheNotFound(cacheKey); + } else { + invalidate.invalidateKey(key); + } + } + + /** + * Invalidate keys for a local near cache. + */ + private void nearCacheInvalidateKeys(String cacheKey, Set keys) { + NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey); + if (invalidate == null) { + warnNearCacheNotFound(cacheKey); + } else { + invalidate.invalidateKeys(keys); + } + } + + /** + * Invalidate clear for a local near cache. + */ + private void nearCacheInvalidateClear(String cacheKey) { + NearCacheInvalidate invalidate = nearCacheMap.get(cacheKey); + if (invalidate == null) { + warnNearCacheNotFound(cacheKey); + } else { + invalidate.invalidateClear(); + } + } + + private void warnNearCacheNotFound(String cacheKey) { + logger.warn("No near cache found for cacheKey [" + cacheKey + "] yet - probably on startup"); + } + +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/RedisCachePlugin.java b/ebean-redis/src/main/java/io/ebean/redis/RedisCachePlugin.java new file mode 100644 index 000000000..27806a4c4 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/RedisCachePlugin.java @@ -0,0 +1,17 @@ +package io.ebean.redis; + +import io.ebean.BackgroundExecutor; +import io.ebean.cache.ServerCacheFactory; +import io.ebean.cache.ServerCachePlugin; +import io.ebean.config.DatabaseConfig; + +public class RedisCachePlugin implements ServerCachePlugin { + + /** + * Create the ServerCacheFactory implementation. + */ + @Override + public ServerCacheFactory create(DatabaseConfig config, BackgroundExecutor executor) { + return new RedisCacheFactory(config, executor); + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/RedisConfig.java b/ebean-redis/src/main/java/io/ebean/redis/RedisConfig.java new file mode 100644 index 000000000..1b738cff9 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/RedisConfig.java @@ -0,0 +1,179 @@ +package io.ebean.redis; + +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; + +import java.util.Properties; + +/** + * Deployment configuration for redis. + */ +public class RedisConfig { + + private String server = "localhost"; + + private int port = 6379; + + private int maxTotal = 200; + + private int maxIdle = 200; + + private int minIdle = 1; + + private long maxWaitMillis = -1L; + + private boolean blockWhenExhausted = true; + + private int timeout = 2000; + + private String username; + + private String password; + + private boolean ssl; + + /** + * Return a new JedisPool based on the configuration. + */ + public JedisPool createPool() { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + poolConfig.setMaxTotal(maxTotal); + poolConfig.setMaxIdle(maxIdle); + poolConfig.setMinIdle(minIdle); + poolConfig.setMaxWaitMillis(maxWaitMillis); + poolConfig.setBlockWhenExhausted(blockWhenExhausted); + return new JedisPool(poolConfig, server, port, timeout, username, password, ssl); + } + + public String getServer() { + return server; + } + + public void setServer(String server) { + this.server = server; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public int getMaxTotal() { + return maxTotal; + } + + public void setMaxTotal(int maxTotal) { + this.maxTotal = maxTotal; + } + + public int getMaxIdle() { + return maxIdle; + } + + public void setMaxIdle(int maxIdle) { + this.maxIdle = maxIdle; + } + + public int getMinIdle() { + return minIdle; + } + + public void setMinIdle(int minIdle) { + this.minIdle = minIdle; + } + + public long getMaxWaitMillis() { + return maxWaitMillis; + } + + public void setMaxWaitMillis(long maxWaitMillis) { + this.maxWaitMillis = maxWaitMillis; + } + + public boolean isBlockWhenExhausted() { + return blockWhenExhausted; + } + + public void setBlockWhenExhausted(boolean blockWhenExhausted) { + this.blockWhenExhausted = blockWhenExhausted; + } + + public int getTimeout() { + return timeout; + } + + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isSsl() { + return ssl; + } + + public void setSsl(boolean ssl) { + this.ssl = ssl; + } + + public void loadProperties(Properties properties) { + Reader reader = new Reader(properties); + this.server = reader.get("ebean.redis.server", server); + this.port = reader.getInt("ebean.redis.port", port); + this.ssl = reader.getBool("ebean.redis.ssl", ssl); + this.minIdle = reader.getInt("ebean.redis.minIdle", minIdle); + this.maxIdle = reader.getInt("ebean.redis.maxIdle", maxIdle); + this.maxTotal = reader.getInt("ebean.redis.maxTotal", maxTotal); + this.maxWaitMillis = reader.getLong("ebean.redis.maxWaitMillis", maxWaitMillis); + this.timeout = reader.getInt("ebean.redis.timeout", timeout); + this.username = reader.get("ebean.redis.username", username); + this.password = reader.get("ebean.redis.password", password); + this.blockWhenExhausted = reader.getBool("ebean.redis.blockWhenExhausted", blockWhenExhausted); + } + + private static class Reader { + + private final Properties properties; + + Reader(Properties properties) { + this.properties = (properties != null) ? properties : new Properties(); + } + + String get(String key, String defaultVal) { + return System.getProperty(key, properties.getProperty(key, defaultVal)); + } + + int getInt(String key, int defaultVal) { + final String val = get(key, null); + return val != null ? Integer.parseInt(val.trim()) : defaultVal; + } + + long getLong(String key, long defaultVal) { + final String val = get(key, null); + return val != null ? Integer.parseInt(val.trim()) : defaultVal; + } + + boolean getBool(String key, boolean defaultVal) { + final String val = get(key, null); + return val != null ? Boolean.parseBoolean(val.trim()) : defaultVal; + } + } + +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/Encode.java b/ebean-redis/src/main/java/io/ebean/redis/encode/Encode.java new file mode 100644 index 000000000..331f11dd2 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/Encode.java @@ -0,0 +1,8 @@ +package io.ebean.redis.encode; + +public interface Encode { + + byte[] encode(Object value); + + Object decode(byte[] data); +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeBeanData.java b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeBeanData.java new file mode 100644 index 000000000..5dd9e31f2 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeBeanData.java @@ -0,0 +1,47 @@ +package io.ebean.redis.encode; + +import io.ebeaninternal.server.cache.CachedBeanData; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class EncodeBeanData implements Encode { + + @Override + public byte[] encode(Object value) { + + try { + CachedBeanData data = (CachedBeanData) value; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(os); + + data.writeExternal(oos); + oos.flush(); + oos.close(); + + return os.toByteArray(); + + } catch (IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } + + @Override + public Object decode(byte[] data) { + + try { + ByteArrayInputStream is = new ByteArrayInputStream(data); + ObjectInputStream ois = new ObjectInputStream(is); + + CachedBeanData read = new CachedBeanData(); + read.readExternal(ois); + return read; + + } catch (ClassNotFoundException | IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeManyIdsData.java b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeManyIdsData.java new file mode 100644 index 000000000..dc33b3bb4 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeManyIdsData.java @@ -0,0 +1,47 @@ +package io.ebean.redis.encode; + +import io.ebeaninternal.server.cache.CachedManyIds; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class EncodeManyIdsData implements Encode { + + @Override + public byte[] encode(Object value) { + + try { + CachedManyIds data = (CachedManyIds) value; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(os); + + data.writeExternal(oos); + oos.flush(); + oos.close(); + + return os.toByteArray(); + + } catch (IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } + + @Override + public Object decode(byte[] data) { + + try { + ByteArrayInputStream is = new ByteArrayInputStream(data); + ObjectInputStream ois = new ObjectInputStream(is); + + CachedManyIds read = new CachedManyIds(); + read.readExternal(ois); + return read; + + } catch (ClassNotFoundException | IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/EncodePrefixKey.java b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodePrefixKey.java new file mode 100644 index 000000000..fd6605d24 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodePrefixKey.java @@ -0,0 +1,41 @@ +package io.ebean.redis.encode; + +import io.ebean.cache.TenantAwareKey; + +import java.nio.charset.StandardCharsets; + +public class EncodePrefixKey implements Encode { + + private final String prefix; + + public EncodePrefixKey(String cacheKey) { + this.prefix = cacheKey + ":"; + } + + @Override + public byte[] encode(Object value) { + try { + if (!(value instanceof String) && !(value instanceof TenantAwareKey.CacheKey)) { + throw new IllegalStateException("Expecting String keys but got type:" + value.getClass()); + } + + String key = prefix + value.toString(); + return key.getBytes(StandardCharsets.UTF_8); + + } catch (Exception e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } + + @Override + public Object decode(byte[] data) { + try { + String key = new String(data, StandardCharsets.UTF_8); + int pos = key.indexOf(':'); + return key.substring(pos); + + } catch (Exception e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeSerializable.java b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeSerializable.java new file mode 100644 index 000000000..5a63b243c --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/EncodeSerializable.java @@ -0,0 +1,37 @@ +package io.ebean.redis.encode; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class EncodeSerializable implements Encode { + + @Override + public byte[] encode(Object value) { + + try { + ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream()); + oos.writeObject(value); + oos.flush(); + oos.close(); + + return new ByteArrayOutputStream().toByteArray(); + + } catch (IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } + + @Override + public Object decode(byte[] data) { + try { + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { + return ois.readObject(); + } + } catch (ClassNotFoundException | IOException e) { + throw new RuntimeException("Failed to decode cache data", e); + } + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/encode/PrefixKey.java b/ebean-redis/src/main/java/io/ebean/redis/encode/PrefixKey.java new file mode 100644 index 000000000..58aca8375 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/encode/PrefixKey.java @@ -0,0 +1,22 @@ +package io.ebean.redis.encode; + +import java.io.Serializable; + +public class PrefixKey implements Serializable { + + private final String prefix; + private final Object key; + + PrefixKey(String prefix, Object key) { + this.prefix = prefix; + this.key = key; + } + + public String getPrefix() { + return prefix; + } + + public Object getKey() { + return key; + } +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopic.java b/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopic.java new file mode 100644 index 000000000..31fdcddd5 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopic.java @@ -0,0 +1,21 @@ +package io.ebean.redis.topic; + +import redis.clients.jedis.Jedis; + +/** + * Topic subscriber that has re-connect notification. + */ +public interface DaemonTopic { + + /** + * Subscribe to the topic/channel (blocking). + * + * @param jedis The redis connection to subscribe (and block on). + */ + void subscribe(Jedis jedis); + + /** + * Notify that the topic subscription has been connected (or reconnected). + */ + void notifyConnected(); +} diff --git a/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopicRunner.java b/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopicRunner.java new file mode 100644 index 000000000..2771c0766 --- /dev/null +++ b/ebean-redis/src/main/java/io/ebean/redis/topic/DaemonTopicRunner.java @@ -0,0 +1,95 @@ +package io.ebean.redis.topic; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.exceptions.JedisException; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * Subscribe to redis topic listening for changes. + *

+ * Handles reconnection to redis when the connection is lost and + * notification of when reconnection takes place. + *

+ */ +public class DaemonTopicRunner { + + private static final Logger log = LoggerFactory.getLogger(DaemonTopicRunner.class); + + private static final long reconnectWaitMillis = 1000; + + private final JedisPool jedisPool; + + private final DaemonTopic daemonTopic; + + public DaemonTopicRunner(JedisPool jedisPool, DaemonTopic daemonTopic) { + this.jedisPool = jedisPool; + this.daemonTopic = daemonTopic; + } + + public void run() { + Thread t = new Thread(() -> attemptConnections(), "redis-sub"); + t.start(); + } + + private void attemptConnections() { + + Timer reloadTimer = new Timer("redis-sub-notify"); + ReloadNotifyTask notifyTask = null; + int attempts = 1; + + while (true) { + if (notifyTask != null) { + // we didn't successfully re-connect to redis + notifyTask.cancel(); + } + notifyTask = new ReloadNotifyTask(); + reloadTimer.schedule(notifyTask, reconnectWaitMillis + 500); + attempts++; + try { + subscribe(); + } catch (JedisException e) { + log.debug("... redis subscribe connection attempt:{} failed:{}", attempts, e.getMessage()); + try { + // wait a little before retrying + Thread.sleep(reconnectWaitMillis); + } catch (InterruptedException e1) { + log.warn("Interrupted redis re-connection wait", e1); + } + } + } + } + + /** + * Subscribe and block when successful. + */ + private void subscribe() { + Jedis jedis = jedisPool.getResource(); + jedis.echo("hi"); + try { + daemonTopic.subscribe(jedis); + + } catch (Exception e) { + log.error("Lost connection to topic, starting re-connection loop", e); + attemptConnections(); + + } finally { + try { + jedis.close(); + } catch (Exception e) { + log.warn("Error closing probably broken Redis connection", e); + } + } + } + + private class ReloadNotifyTask extends TimerTask { + @Override + public void run() { + daemonTopic.notifyConnected(); + } + } +} diff --git a/ebean-redis/src/main/resources/META-INF/services/io.ebean.cache.ServerCachePlugin b/ebean-redis/src/main/resources/META-INF/services/io.ebean.cache.ServerCachePlugin new file mode 100644 index 000000000..577a7fc5a --- /dev/null +++ b/ebean-redis/src/main/resources/META-INF/services/io.ebean.cache.ServerCachePlugin @@ -0,0 +1 @@ +io.ebean.redis.RedisCachePlugin diff --git a/ebean-redis/src/test/java/io/ebean/redis/RedisConfigTest.java b/ebean-redis/src/test/java/io/ebean/redis/RedisConfigTest.java new file mode 100644 index 000000000..0b149bb87 --- /dev/null +++ b/ebean-redis/src/test/java/io/ebean/redis/RedisConfigTest.java @@ -0,0 +1,46 @@ +package io.ebean.redis; + +import org.junit.Test; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RedisConfigTest { + + @Test + public void loadProperties() { + + Properties p = new Properties(); + p.setProperty("ebean.redis.server", "test-server"); + p.setProperty("ebean.redis.port", "99"); + p.setProperty("ebean.redis.maxIdle", "5"); + p.setProperty("ebean.redis.maxTotal", "6"); + p.setProperty("ebean.redis.minIdle", "7"); + p.setProperty("ebean.redis.maxWaitMillis", "8"); + p.setProperty("ebean.redis.username", "un"); + p.setProperty("ebean.redis.password", "pw"); + p.setProperty("ebean.redis.ssl", "true"); + + RedisConfig config = new RedisConfig(); + config.loadProperties(p); + + assertThat(config.getServer()).isEqualTo("test-server"); + assertThat(config.getPort()).isEqualTo(99); + assertThat(config.getMaxIdle()).isEqualTo(5); + assertThat(config.getMaxTotal()).isEqualTo(6); + assertThat(config.getMinIdle()).isEqualTo(7); + assertThat(config.getMaxWaitMillis()).isEqualTo(8); + assertThat(config.getUsername()).isEqualTo("un"); + assertThat(config.getPassword()).isEqualTo("pw"); + assertThat(config.isSsl()).isEqualTo(true); + } + + @Test + public void test_defaultValues() { + RedisConfig config = new RedisConfig(); + assertThat(config.getUsername()).isNull(); + assertThat(config.getPassword()).isNull(); + assertThat(config.isSsl()).isEqualTo(false); + } +} diff --git a/ebean-redis/src/test/java/org/domain/EBase.java b/ebean-redis/src/test/java/org/domain/EBase.java new file mode 100644 index 000000000..84143f8e3 --- /dev/null +++ b/ebean-redis/src/test/java/org/domain/EBase.java @@ -0,0 +1,58 @@ +package org.domain; + +import io.ebean.Model; +import io.ebean.annotation.WhenCreated; +import io.ebean.annotation.WhenModified; + +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import javax.persistence.Version; +import java.time.Instant; + +@MappedSuperclass +public class EBase extends Model { + + @Id + protected long id; + + @Version + protected long version; + + @WhenCreated + protected Instant whenCreated; + + @WhenModified + protected Instant whenModified; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public Instant getWhenCreated() { + return whenCreated; + } + + public void setWhenCreated(Instant whenCreated) { + this.whenCreated = whenCreated; + } + + public Instant getWhenModified() { + return whenModified; + } + + public void setWhenModified(Instant whenModified) { + this.whenModified = whenModified; + } +} diff --git a/ebean-redis/src/test/java/org/domain/Person.java b/ebean-redis/src/test/java/org/domain/Person.java new file mode 100644 index 000000000..b2a5d2110 --- /dev/null +++ b/ebean-redis/src/test/java/org/domain/Person.java @@ -0,0 +1,69 @@ +package org.domain; + + +import io.ebean.annotation.Cache; +import io.ebean.annotation.Index; + +import javax.persistence.Entity; +import java.time.LocalDate; + +@Cache(enableQueryCache = true, nearCache = true, naturalKey = "name") +@Entity +public class Person extends EBase { + + public enum Status { + NEW, + ACTIVE, + INACTIVE + } + + @Index(unique = true) + String name; + + Status status; + + LocalDate localDate; + + String notes; + + public Person(String name) { + this.name = name; + this.status = Status.NEW; + } + + public String toString() { + return "[id:" + id + " name:" + name + "date:" + localDate + ']'; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public LocalDate getLocalDate() { + return localDate; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } +} diff --git a/ebean-redis/src/test/java/org/integration/ClusterTest.java b/ebean-redis/src/test/java/org/integration/ClusterTest.java new file mode 100644 index 000000000..0d9414579 --- /dev/null +++ b/ebean-redis/src/test/java/org/integration/ClusterTest.java @@ -0,0 +1,123 @@ +package org.integration; + +import io.ebean.redis.DuelCache; +import org.domain.Person; +import io.ebean.DB; +import io.ebean.Database; +import io.ebean.DatabaseFactory; +import io.ebean.config.DatabaseConfig; +import org.domain.query.QPerson; +import org.junit.Test; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ClusterTest { + + + private Database createOther(DataSource dataSource) { + + DatabaseConfig config = new DatabaseConfig(); + config.setDataSource(dataSource); + config.loadFromProperties(); + config.setDefaultServer(false); + config.setName("other"); + config.setDdlGenerate(false); + config.setDdlRun(false); + + return DatabaseFactory.create(config); + } + + @Test + public void testBothNear() { + // ensure the default server exists first + final Database db = DB.getDefault(); + Database other = createOther(db.getPluginApi().getDataSource()); + + new QPerson() + .name.eq("Someone") + .delete(); + + Person foo = new Person("Someone"); + foo.save(); + + DB.getServerCacheManager().clearAll(); + other.getMetaInfoManager().resetAllMetrics(); + + Person fooA = DB.find(Person.class, foo.getId()); + Person fooB = other.find(Person.class, foo.getId()); + fooA = DB.find(Person.class, foo.getId()); + fooB = other.find(Person.class, foo.getId()); + fooA = DB.find(Person.class, foo.getId()); + fooB = other.find(Person.class, foo.getId()); + + DuelCache dualCache = (DuelCache) other.getServerCacheManager().getBeanCache(Person.class); + assertCounts(dualCache, 2, 1, 1, 0); + + } + + @Test + public void test() throws InterruptedException { + + // ensure the default server exists first + final Database db = DB.getDefault(); + + Database other = createOther(db.getPluginApi().getDataSource()); + + for (int i = 0; i < 10; i++) { + Person foo = new Person("name " + i); + foo.save(); + } + + other.getServerCacheManager().clearAll(); + other.getMetaInfoManager().resetAllMetrics(); + + DuelCache dualCache = (DuelCache) other.getServerCacheManager().getBeanCache(Person.class); + + Person foo0 = other.find(Person.class, 1); + assertCounts(dualCache, 0, 1, 0, 1); + + other.find(Person.class, 1); + assertCounts(dualCache, 1, 1, 0, 1); + + other.find(Person.class, 1); + assertCounts(dualCache, 2, 1, 0, 1); + + other.find(Person.class, 1); + assertCounts(dualCache, 3, 1, 0, 1); + + other.find(Person.class, 2); + assertCounts(dualCache, 3, 2, 0, 2); + + + foo0.setName("name2"); + foo0.save(); + allowAsyncMessaging(); + + Person foo3 = other.find(Person.class, 1); + assertThat(foo3.getName()).isEqualTo("name2"); + assertCounts(dualCache, 3, 3, 1, 2); + + + foo0.setName("name3"); + foo0.save(); + allowAsyncMessaging(); + + foo3 = other.find(Person.class, 1); + assertThat(foo3.getName()).isEqualTo("name3"); + assertCounts(dualCache, 3, 4, 2, 2); + } + + private void assertCounts(DuelCache dualCache, int nearHits, int nearMiss, int remoteHit, int remoteMiss) { + + assertThat(dualCache.getNearHitCount()).isEqualTo(nearHits); + assertThat(dualCache.getNearMissCount()).isEqualTo(nearMiss); + assertThat(dualCache.getRemoteHitCount()).isEqualTo(remoteHit); + assertThat(dualCache.getRemoteMissCount()).isEqualTo(remoteMiss); + } + + private void allowAsyncMessaging() throws InterruptedException { + Thread.sleep(10); + } +} diff --git a/ebean-redis/src/test/java/org/integration/IntegrationTest.java b/ebean-redis/src/test/java/org/integration/IntegrationTest.java new file mode 100644 index 000000000..419b8d78c --- /dev/null +++ b/ebean-redis/src/test/java/org/integration/IntegrationTest.java @@ -0,0 +1,107 @@ +package org.integration; + +import io.ebean.DB; +import org.domain.Person; +import org.domain.query.QPerson; +import org.junit.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IntegrationTest { + + @Test + public void test() throws InterruptedException { + + insertSomePeople(); + + Person fiona = findByName("Fiona"); + fiona.setName("Fortuna"); + fiona.setLocalDate(LocalDate.now()); + fiona.update(); + + Thread.sleep(100); + + Person one = findById(1); + assertThat(one).isNotNull(); + + for (int i = 1; i < 4; i++) { + System.out.println("hit " + findById(i)); + } + + List one2 = nameStartsWith("fo"); + assertThat(one2).hasSize(1); + + one2 = nameStartsWith("j"); + assertThat(one2).hasSize(2); + + one2 = nameStartsWith("j"); + assertThat(one2).hasSize(2); + + + List byNames = findByNames("Jack", "Rob"); + assertThat(byNames).hasSize(2); + + byNames = findByNames("Jack", "Rob", "Moby"); + assertThat(byNames).hasSize(3); + + fiona.setName("fo2"); + fiona.setLocalDate(LocalDate.now()); + fiona.update(); + + byNames = findByNames("Jack", "Rob", "Moby"); + assertThat(byNames).hasSize(3); + + Thread.sleep(200); + + one2 = nameStartsWith("fo%"); + System.out.println("one2 " + one2); + one2 = nameStartsWith("f0%"); + + System.out.println("one2 " + one2); + + DB.getServerCacheManager().clear(Person.class); + + System.out.println("done"); + } + + private List insertSomePeople() { + + List people = new ArrayList<>(); + for (String name : new String[]{"Jack", "John", "Rob", "Moby", "Fiona"}) { + people.add(new Person(name)); + } + + DB.saveAll(people); + return people; + } + + private Person findByName(String name) { + return new QPerson() + .name.eq(name) + .findOne(); + } + + private List findByNames(String... names) { + return new QPerson() + .name.in(names) + .setUseCache(true) + .findList(); + } + + private Person findById(int id) { + return new QPerson() + .id.eq(id) + .findOne(); + } + + private List nameStartsWith(String pattern) { + return new QPerson() + .name.istartsWith(pattern) + .setUseQueryCache(true) + .findList(); + } +} diff --git a/ebean-redis/src/test/resources/application-test.yaml b/ebean-redis/src/test/resources/application-test.yaml new file mode 100644 index 000000000..870423533 --- /dev/null +++ b/ebean-redis/src/test/resources/application-test.yaml @@ -0,0 +1,9 @@ +ebean: + dumpMetricsOnShutdown: true + dumpMetricsOptions: sql,hash,loc + test: + redis: latest +# shutdown: stop # stop | remove + platform: h2 # h2, postgres, mysql, oracle, sqlserver, sqlite + ddlMode: dropCreate # none | dropCreate | create | migration | createOnly | migrationDropCreate + dbName: myapp diff --git a/ebean-redis/src/test/resources/logback-test.xml b/ebean-redis/src/test/resources/logback-test.xml new file mode 100644 index 000000000..81299d802 --- /dev/null +++ b/ebean-redis/src/test/resources/logback-test.xml @@ -0,0 +1,29 @@ + + + + TRACE + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 8fe431a0c..6350ca0af 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,7 @@ kotlin-querybean-generator ebean-querybean ebean-postgis + ebean-redis