/* * (C) Copyright 2015 Nuxeo SA (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: * Thierry Delprat <tdelprat@nuxeo.com> * Antoine Taillefer <ataillefer@nuxeo.com> */ package org.nuxeo.ecm.core.redis.contribs; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.NuxeoException; import org.nuxeo.ecm.core.redis.RedisAdmin; import org.nuxeo.ecm.core.redis.RedisCallable; import org.nuxeo.ecm.core.redis.RedisExecutor; import org.nuxeo.ecm.core.transientstore.AbstractTransientStore; import org.nuxeo.ecm.core.transientstore.api.TransientStore; import org.nuxeo.ecm.core.transientstore.api.TransientStoreConfig; import org.nuxeo.runtime.api.Framework; /** * Redis implementation (i.e. cluster aware) of the {@link TransientStore}. * <p> * Since hashes cannot be nested, a storage entry is flattened as follows: * * <pre> * - Entry summary: * * transientStore:transientStoreName:entryKey { * "blobCount": number of blobs associated with the entry * "size": storage size of the blobs associated with the entry * "completed": entry status * } * * - Entry parameters: * * transientStore:transientStoreName:entryKey:params { * "param1": value1 * "param2": value2 * } * * - Entry blobs: * * transientStore:transientStoreName:entryKey:blobs:0 { * "file" * "filename" * "encoding" * "mimetype" * "digest" * } * * transientStore:transientStoreName:entryKey:blobs:1 { * ... * } * * ... * </pre> * * @since 7.2 */ public class RedisTransientStore extends AbstractTransientStore { protected static final String SIZE_KEY = "size"; protected RedisExecutor redisExecutor; protected String namespace; protected String sizeKey; protected KeyMatcher keyMatcher; protected RedisAdmin redisAdmin; protected int firstLevelTTL; protected int secondLevelTTL; protected Log log = LogFactory.getLog(RedisTransientStore.class); public RedisTransientStore() { redisExecutor = Framework.getService(RedisExecutor.class); redisAdmin = Framework.getService(RedisAdmin.class); } @Override public void init(TransientStoreConfig config) { log.debug("Initializing RedisTransientStore: " + config.getName()); super.init(config); namespace = redisAdmin.namespace("transientStore", config.getName()); sizeKey = namespace + SIZE_KEY; keyMatcher = new KeyMatcher(); // Use seconds for Redis EXPIRE command firstLevelTTL = config.getFirstLevelTTL() * 60; secondLevelTTL = config.getSecondLevelTTL() * 60; } @Override public void shutdown() { log.debug("Shutting down RedisTransientStore: " + config.getName()); // Nothing to do here. } @Override public boolean exists(String key) { // Jedis#exists(String key) doesn't to work for a key created with hset or hmset return getSummary(key) != null || getParameters(key) != null; } @Override public Set<String> keySet() { return redisExecutor.execute((RedisCallable<Set<String>>) jedis -> { return jedis.keys(namespace + "*") .stream() .map(keyMatcher) .filter(key -> !SIZE_KEY.equals(key)) .collect(Collectors.toSet()); }); } protected class KeyMatcher implements Function<String, String> { protected final Pattern KEY_PATTERN = Pattern.compile("(.*?)(:(params|blobs:[0-9]+))?"); protected final int offset = namespace.length(); @Override public String apply(String t) { final Matcher m = KEY_PATTERN.matcher(t.substring(offset)); m.matches(); return m.group(1); } } @Override public void putParameter(String key, String parameter, Serializable value) { redisExecutor.execute((RedisCallable<Void>) jedis -> { String paramsKey = namespace + join(key, "params"); if (log.isDebugEnabled()) { log.debug(String.format("Setting field %s to value %s in Redis hash stored at key %s", parameter, value, paramsKey)); } jedis.hset(getBytes(paramsKey), getBytes(parameter), serialize(value)); return null; }); setTTL(key, firstLevelTTL); } @Override public Serializable getParameter(String key, String parameter) { return redisExecutor.execute((RedisCallable<Serializable>) jedis -> { String paramsKey = namespace + join(key, "params"); byte[] paramBytes = jedis.hget(getBytes(paramsKey), getBytes(parameter)); if (paramBytes == null) { return null; } Serializable res = deserialize(paramBytes); if (log.isDebugEnabled()) { log.debug(String.format("Fetched field %s from Redis hash stored at key %s -> %s", parameter, paramsKey, res)); } return res; }); } @Override public void putParameters(String key, Map<String, Serializable> parameters) { redisExecutor.execute((RedisCallable<Void>) jedis -> { String paramsKey = namespace + join(key, "params"); if (log.isDebugEnabled()) { log.debug(String.format("Setting fields %s in Redis hash stored at key %s", parameters, paramsKey)); } jedis.hmset(getBytes(paramsKey), serialize(parameters)); return null; }); setTTL(key, firstLevelTTL); } @Override public Map<String, Serializable> getParameters(String key) { // TODO NXP-18236: use a transaction? String paramsKey = namespace + join(key, "params"); Map<byte[], byte[]> paramBytes = redisExecutor.execute((RedisCallable<Map<byte[], byte[]>>) jedis -> { return jedis.hgetAll(getBytes(paramsKey)); }); if (paramBytes.isEmpty()) { if (getSummary(key) == null) { return null; } else { return new HashMap<>(); } } Map<String, Serializable> res = deserialize(paramBytes); if (log.isDebugEnabled()) { log.debug(String.format("Fetched fields from Redis hash stored at key %s -> %s", paramsKey, res)); } return res; } @Override public List<Blob> getBlobs(String key) { // TODO NXP-18236: use a transaction? // Get blob count String blobCount = redisExecutor.execute((RedisCallable<String>) jedis -> { return jedis.hget(namespace + key, "blobCount"); }); if (log.isDebugEnabled()) { log.debug(String.format("Fetched field \"blobCount\" from Redis hash stored at key %s -> %s", namespace + key, blobCount)); } if (blobCount == null) { // Check for existing parameters Map<String, Serializable> parameters = getParameters(key); if (parameters == null) { return null; } else { return new ArrayList<Blob>(); } } // Get blobs int entryBlobCount = Integer.parseInt(blobCount); if (entryBlobCount <= 0) { return new ArrayList<>(); } List<Map<String, String>> blobInfos = new ArrayList<>(); for (int i = 0; i < entryBlobCount; i++) { String blobInfoIndex = String.valueOf(i); Map<String, String> entryBlobInfo = redisExecutor.execute((RedisCallable<Map<String, String>>) jedis -> { String blobInfoKey = namespace + join(key, "blobs", blobInfoIndex); Map<String, String> blobInfo = jedis.hgetAll(blobInfoKey); if (blobInfo.isEmpty()) { throw new NuxeoException(String.format( "Entry with key %s is inconsistent: blobCount = %d but key %s doesn't exist", key, entryBlobCount, blobInfoKey)); } if (log.isDebugEnabled()) { log.debug(String.format("Fetched fields from Redis hash stored at key %s -> %s", blobInfoKey, blobInfo)); } return blobInfo; }); blobInfos.add(entryBlobInfo); } // Load blobs from the file system return loadBlobs(blobInfos); } @Override public long getSize(String key) { return redisExecutor.execute((RedisCallable<Long>) jedis -> { String size = jedis.hget(namespace + key, SIZE_KEY); if (size == null) { return -1L; } if (log.isDebugEnabled()) { log.debug(String.format("Fetched field \"%s\" from Redis hash stored at key %s -> %s", SIZE_KEY, namespace + key, size)); } return Long.parseLong(size); }); } @Override public boolean isCompleted(String key) { return redisExecutor.execute((RedisCallable<Boolean>) jedis -> { String completed = jedis.hget(namespace + key, "completed"); if (log.isDebugEnabled()) { log.debug(String.format("Fetched field \"completed\" from Redis hash stored at key %s -> %s", namespace + key, completed)); } return Boolean.parseBoolean(completed); }); } @Override public void setCompleted(String key, boolean completed) { redisExecutor.execute((RedisCallable<Void>) jedis -> { if (log.isDebugEnabled()) { log.debug(String.format("Setting field \"completed\" to value %s in Redis hash stored at key %s", completed, namespace + key)); } jedis.hset(namespace + key, "completed", String.valueOf(completed)); return null; }); setTTL(key, firstLevelTTL); } @Override public void remove(String key) { // TODO NXP-18236: use a transaction? Map<String, String> summary = getSummary(key); if (summary != null) { // Remove blobs String blobCount = summary.get("blobCount"); deleteBlobInfos(key, blobCount); // Remove summary redisExecutor.execute((RedisCallable<Long>) jedis -> { Long deleted = jedis.del(namespace + key); if (log.isDebugEnabled()) { log.debug(String.format("Deleted %d Redis hash stored at key %s", deleted, namespace + key)); } return deleted; }); // Decrement storage size String size = summary.get(SIZE_KEY); if (size != null) { long entrySize = Integer.parseInt(size); if (entrySize > 0) { decrementStorageSize(entrySize); } } } // Remove parameters redisExecutor.execute((RedisCallable<Long>) jedis -> { String paramsKey = namespace + join(key, "params"); Long deleted = jedis.del(getBytes(paramsKey)); if (log.isDebugEnabled()) { log.debug(String.format("Deleted %d Redis hash stored at key %s", deleted, paramsKey)); } return deleted; }); } @Override public void release(String key) { if (getStorageSize() <= config.getTargetMaxSizeMB() * (1024 * 1024) || config.getTargetMaxSizeMB() < 0) { setTTL(key, secondLevelTTL); } else { remove(key); } } @Override protected void persistBlobs(String key, long sizeOfBlobs, List<Map<String, String>> blobInfos) { // TODO NXP-18236: use a transaction? Map<String, String> oldSummary = getSummary(key); // Update storage size long entrySize = -1; if (oldSummary != null) { String size = oldSummary.get(SIZE_KEY); if (size != null) { entrySize = Long.parseLong(size); } } if (entrySize > 0) { incrementStorageSize(sizeOfBlobs - entrySize); } else { if (sizeOfBlobs > 0) { incrementStorageSize(sizeOfBlobs); } } // Delete old blobs if (oldSummary != null) { String oldBlobCount = oldSummary.get("blobCount"); deleteBlobInfos(key, oldBlobCount); } // Update entry size and blob count final Map<String, String> entrySummary = new HashMap<>(); int blobCount = 0; if (blobInfos != null) { blobCount = blobInfos.size(); } entrySummary.put("blobCount", String.valueOf(blobCount)); entrySummary.put(SIZE_KEY, String.valueOf(sizeOfBlobs)); redisExecutor.execute((RedisCallable<Void>) jedis -> { if (log.isDebugEnabled()) { log.debug(String.format("Setting fields %s in Redis hash stored at key %s", entrySummary, namespace + key)); } jedis.hmset(namespace + key, entrySummary); jedis.expire(namespace + key, firstLevelTTL); return null; }); // Set new blobs if (blobInfos != null) { int blobsTimeout = firstLevelTTL + 60; for (int i = 0; i < blobInfos.size(); i++) { String blobInfoIndex = String.valueOf(i); Map<String, String> blobInfo = blobInfos.get(i); redisExecutor.execute((RedisCallable<Void>) jedis -> { String blobInfoKey = namespace + join(key, "blobs", blobInfoIndex); if (log.isDebugEnabled()) { log.debug(String.format("Setting fields %s in Redis hash stored at key %s", blobInfo, blobInfoKey)); } jedis.hmset(blobInfoKey, blobInfo); jedis.expire(blobInfoKey, blobsTimeout); return null; }); } } // Set params TTL redisExecutor.execute((RedisCallable<Void>) jedis -> { String paramsKey = namespace + join(key, "params"); jedis.expire(getBytes(paramsKey), firstLevelTTL + 60); return null; }); } @Override public long getStorageSize() { return redisExecutor.execute((RedisCallable<Long>) jedis -> { String value = jedis.get(sizeKey); if (value == null) { return 0L; } if (log.isDebugEnabled()) { log.debug(String.format("Fetched value of Redis key %s -> %s", sizeKey, value)); } return Long.parseLong(value); }); } @Override protected void setStorageSize(final long newSize) { redisExecutor.execute((RedisCallable<Void>) jedis -> { if (log.isDebugEnabled()) { log.debug(String.format("Setting Redis key %s to value %s", sizeKey, newSize)); } jedis.set(sizeKey, "" + newSize); return null; }); } @Override protected long incrementStorageSize(final long size) { return redisExecutor.execute((RedisCallable<Long>) jedis -> { Long incremented = jedis.incrBy(sizeKey, size); if (log.isDebugEnabled()) { log.debug(String.format("Incremented Redis key %s to %d", sizeKey, incremented)); } return incremented; }); } @Override protected long decrementStorageSize(final long size) { return redisExecutor.execute((RedisCallable<Long>) jedis -> { Long decremented = jedis.decrBy(sizeKey, size); if (log.isDebugEnabled()) { log.debug(String.format("Decremented Redis key %s to %d", sizeKey, decremented)); } return decremented; }); } @Override protected void removeAllEntries() { // TODO NXP-18236: use a transaction? Set<String> keys = redisExecutor.execute((RedisCallable<Set<String>>) jedis -> { return jedis.keys(namespace + "*"); }); for (String key : keys) { redisExecutor.execute((RedisCallable<Void>) jedis -> { jedis.del(key); return null; }); } } public long getTTL(String key) { long summaryTTL = redisExecutor.execute((RedisCallable<Long>) jedis -> { return jedis.ttl(namespace + key); }); if (summaryTTL >= 0) { return summaryTTL; } else { return redisExecutor.execute((RedisCallable<Long>) jedis -> { String paramsKey = namespace + join(key, "params"); return jedis.ttl(getBytes(paramsKey)); }); } } protected Map<String, String> getSummary(String key) { return redisExecutor.execute((RedisCallable<Map<String, String>>) jedis -> { Map<String, String> summary = jedis.hgetAll(namespace + key); if (summary.isEmpty()) { return null; } if (log.isDebugEnabled()) { log.debug(String.format("Fetched fields from Redis hash stored at key %s -> %s", namespace + key, summary)); } return summary; }); } protected void deleteBlobInfos(String key, String blobCountStr) { if (blobCountStr != null) { int blobCount = Integer.parseInt(blobCountStr); if (blobCount > 0) { for (int i = 0; i < blobCount; i++) { String blobInfoIndex = String.valueOf(i); redisExecutor.execute((RedisCallable<Long>) jedis -> { String blobInfoKey = namespace + join(key, "blobs", blobInfoIndex); Long deleted = jedis.del(blobInfoKey); if (log.isDebugEnabled()) { log.debug(String.format("Deleted %d Redis hash stored at key %s", deleted, blobInfoKey)); } return deleted; }); } } } } protected String join(String... fragments) { return StringUtils.join(fragments, ":"); } protected byte[] getBytes(String key) { return key.getBytes(StandardCharsets.UTF_8); } protected String getString(byte[] bytes) { return new String(bytes, StandardCharsets.UTF_8); } protected byte[] serialize(Serializable value) { try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(baos); out.writeObject(value); out.flush(); out.close(); return baos.toByteArray(); } catch (IOException e) { throw new NuxeoException(e); } } protected Serializable deserialize(byte[] bytes) { try { InputStream bain = new ByteArrayInputStream(bytes); ObjectInputStream in = new ObjectInputStream(bain); return (Serializable) in.readObject(); } catch (IOException | ClassNotFoundException e) { throw new NuxeoException(e); } } protected Map<byte[], byte[]> serialize(Map<String, Serializable> map) { Map<byte[], byte[]> serializedMap = new HashMap<>(); for (String key : map.keySet()) { serializedMap.put(getBytes(key), serialize(map.get(key))); } return serializedMap; } protected Map<String, Serializable> deserialize(Map<byte[], byte[]> byteMap) { Map<String, Serializable> map = new HashMap<>(); for (byte[] key : byteMap.keySet()) { map.put(getString(key), deserialize(byteMap.get(key))); } return map; } protected void setTTL(String key, int seconds) { Map<String, String> summary = getSummary(key); if (summary != null) { // Summary redisExecutor.execute((RedisCallable<Void>) jedis -> { jedis.expire(namespace + key, seconds); return null; }); // Blobs String blobCountStr = summary.get("blobCount"); if (blobCountStr != null) { int blobCount = Integer.parseInt(blobCountStr); if (blobCount > 0) { final int blobsTimeout = seconds + 60; for (int i = 0; i < blobCount; i++) { String blobInfoIndex = String.valueOf(i); redisExecutor.execute((RedisCallable<Void>) jedis -> { String blobInfoKey = namespace + join(key, "blobs", blobInfoIndex); jedis.expire(blobInfoKey, blobsTimeout); return null; }); } } } } // Parameters final int paramsTimeout; if (summary == null) { paramsTimeout = seconds; } else { paramsTimeout = seconds + 60; } redisExecutor.execute((RedisCallable<Void>) jedis -> { String paramsKey = namespace + join(key, "params"); jedis.expire(getBytes(paramsKey), paramsTimeout); return null; }); } }