diff --git a/evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java b/evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java index cefa384d..40711158 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java +++ b/evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java @@ -6,6 +6,7 @@ import com.netflix.archaius.api.PropertyRepository; import com.netflix.evcache.EVCacheInMemoryCache.DataNotFoundException; import com.netflix.evcache.EVCacheLatch.Policy; +import com.netflix.evcache.config.EVCacheTranscoderProperties; import com.netflix.evcache.dto.KeyMapDto; import com.netflix.evcache.event.EVCacheEvent; import com.netflix.evcache.event.EVCacheEventListener; @@ -76,6 +77,7 @@ public class EVCacheImpl implements EVCache, EVCacheImplMBean { private static final Logger log = LoggerFactory.getLogger(EVCacheImpl.class); + private static final int COMPRESSION_THRESHOLD_BYTES = Integer.MAX_VALUE; private final Clock clock; private final String _appName; @@ -164,9 +166,9 @@ public class EVCacheImpl implements EVCache, EVCacheImplMBean { this.maxHashLength = propertyRepository.get(appName + ".max.hash.length", Integer.class).orElse(-1); this.encoderBase = propertyRepository.get(appName + ".hash.encoder", String.class).orElse("base64"); this.autoHashKeys = propertyRepository.get(_appName + ".auto.hash.keys", Boolean.class).orElseGet("evcache.auto.hash.keys").orElse(false); - this.evcacheValueTranscoder = new EVCacheTranscoder(); - evcacheValueTranscoder.setCompressionThreshold(Integer.MAX_VALUE); - + final EVCacheTranscoderProperties evCacheTranscoderProperties = new EVCacheTranscoderProperties(_appName, propertyRepository); + this.evcacheValueTranscoder = new EVCacheTranscoder(evCacheTranscoderProperties); + evcacheValueTranscoder.setCompressionThreshold(COMPRESSION_THRESHOLD_BYTES); // default max key length is 200, instead of using what is defined in MemcachedClientIF.MAX_KEY_LENGTH (250). This is to accommodate // auto key prepend with appname for duet feature. this.maxKeyLength = propertyRepository.get(_appName + ".max.key.length", Integer.class).orElseGet("evcache.max.key.length").orElse(200); diff --git a/evcache-core/src/main/java/com/netflix/evcache/EVCacheSerializingTranscoder.java b/evcache-core/src/main/java/com/netflix/evcache/EVCacheSerializingTranscoder.java index 95c7a86e..5ab7f83f 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/EVCacheSerializingTranscoder.java +++ b/evcache-core/src/main/java/com/netflix/evcache/EVCacheSerializingTranscoder.java @@ -22,8 +22,10 @@ package com.netflix.evcache; +import com.netflix.evcache.config.EVCacheTranscoderProperties; import com.netflix.evcache.metrics.EVCacheMetricsFactory; import com.netflix.evcache.pool.ServerGroup; +import com.netflix.evcache.util.EVCacheConfig; import com.netflix.spectator.api.BasicTag; import com.netflix.spectator.api.Tag; import com.netflix.spectator.api.Timer; @@ -68,6 +70,8 @@ public class EVCacheSerializingTranscoder extends BaseSerializingTranscoder impl private final TranscoderUtils tu = new TranscoderUtils(true); private Timer timer; + protected final EVCacheTranscoderProperties properties; + /** * Get a serializing transcoder with the default max data size. */ @@ -76,10 +80,23 @@ public EVCacheSerializingTranscoder() { } /** - * Get a serializing transcoder that specifies the max data size. + * Get a serializing transcoder that specifies the max data size. Builds a default + * {@link EVCacheTranscoderProperties} bundle from + * {@link EVCacheConfig#getInstance()} — subclasses/callers that want per-app + * resolution should use {@link #EVCacheSerializingTranscoder(EVCacheTranscoderProperties, int)}. */ public EVCacheSerializingTranscoder(int max) { + this(new EVCacheTranscoderProperties(null, EVCacheConfig.getInstance().getPropertyRepository()), max); + } + + /** + * Get a serializing transcoder with the supplied transcoder-property bundle. The bundle is + * exposed to subclasses via {@link #properties} so downstream transcoders can consult the + * same three-level (per-app → global → static default) resolution chain. + */ + public EVCacheSerializingTranscoder(EVCacheTranscoderProperties properties, int max) { super(max); + this.properties = properties; } @Override diff --git a/evcache-core/src/main/java/com/netflix/evcache/EVCacheTranscoder.java b/evcache-core/src/main/java/com/netflix/evcache/EVCacheTranscoder.java index 97be808b..e199a5ff 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/EVCacheTranscoder.java +++ b/evcache-core/src/main/java/com/netflix/evcache/EVCacheTranscoder.java @@ -1,21 +1,53 @@ package com.netflix.evcache; +import com.netflix.evcache.config.EVCacheTranscoderProperties; +import com.netflix.evcache.pool.EVCacheValue; +import com.netflix.evcache.pool.EVCacheValueSerde; import com.netflix.evcache.util.EVCacheConfig; import net.spy.memcached.CachedData; +import static com.netflix.evcache.config.EVCacheTranscoderProperties.DEFAULT_COMPRESSION_THRESHOLD_BYTES; +import static com.netflix.evcache.config.EVCacheTranscoderProperties.DEFAULT_MAX_DATA_SIZE_BYTES; +import static com.netflix.evcache.config.EVCacheTranscoderProperties.Key.COMPRESSION_THRESHOLD_BYTES; +import static com.netflix.evcache.config.EVCacheTranscoderProperties.Key.MAX_DATA_SIZE_BYTES; + public class EVCacheTranscoder extends EVCacheSerializingTranscoder { + /** + * @param properties the transcoder property bundle. + * {@link EVCacheTranscoderProperties.Key#MAX_DATA_SIZE_BYTES} and + * {@link EVCacheTranscoderProperties.Key#COMPRESSION_THRESHOLD_BYTES} + * are read from the bundle at construction and set as fields on the + * underlying {@link EVCacheSerializingTranscoder} for legacy reasons; + * new properties should be added to {@link EVCacheTranscoderProperties} + * rather than plumbed through further constructor arguments. + */ + public EVCacheTranscoder(EVCacheTranscoderProperties properties) { + this(properties.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, DEFAULT_MAX_DATA_SIZE_BYTES).get(), + properties.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, DEFAULT_COMPRESSION_THRESHOLD_BYTES).get(), + properties + ); + } + public EVCacheTranscoder() { - this(EVCacheConfig.getInstance().getPropertyRepository().get("default.evcache.max.data.size", Integer.class).orElse(20 * 1024 * 1024).get()); + this(new EVCacheTranscoderProperties(null, EVCacheConfig.getInstance().getPropertyRepository())); } public EVCacheTranscoder(int max) { - this(max, EVCacheConfig.getInstance().getPropertyRepository().get("default.evcache.compression.threshold", Integer.class).orElse(120).get()); + this(max, new EVCacheTranscoderProperties(null, EVCacheConfig.getInstance().getPropertyRepository())); } public EVCacheTranscoder(int max, int compressionThreshold) { - super(max); + this(max, compressionThreshold, new EVCacheTranscoderProperties(null, EVCacheConfig.getInstance().getPropertyRepository())); + } + + private EVCacheTranscoder(int max, EVCacheTranscoderProperties properties) { + this(max, properties.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, DEFAULT_COMPRESSION_THRESHOLD_BYTES).get(), properties); + } + + private EVCacheTranscoder(int max, int compressionThreshold, EVCacheTranscoderProperties properties) { + super(properties, max); setCompressionThreshold(compressionThreshold); } @@ -35,4 +67,20 @@ public CachedData encode(Object o) { return super.encode(o); } + @Override + protected byte[] serialize(Object o) { + if (this.properties.isBinarySerializationEnabled() && o instanceof EVCacheValue) { + return EVCacheValueSerde.serialize((EVCacheValue) o); + } + return super.serialize(o); + } + + @Override + protected Object deserialize(byte[] in) { + if (EVCacheValueSerde.isBinaryFormat(in)) { + return EVCacheValueSerde.deserialize(in); + } + return super.deserialize(in); + } + } diff --git a/evcache-core/src/main/java/com/netflix/evcache/config/EVCacheTranscoderProperties.java b/evcache-core/src/main/java/com/netflix/evcache/config/EVCacheTranscoderProperties.java new file mode 100644 index 00000000..f520df73 --- /dev/null +++ b/evcache-core/src/main/java/com/netflix/evcache/config/EVCacheTranscoderProperties.java @@ -0,0 +1,93 @@ +package com.netflix.evcache.config; + +import com.netflix.archaius.api.Property; +import com.netflix.archaius.api.PropertyRepository; + +import static com.netflix.evcache.config.EVCacheTranscoderProperties.Key.BINARY_SERIALIZATION_ENABLED; + +/** + * Properties related to {@link com.netflix.evcache.EVCacheTranscoder} + * behavior. + * + *
Every property resolves at construction through: + * + *
+ * Static properties should be cached as a field for fast access.
+ * Dynamic properties get be accessed {@link #getProperty(Key, Class, Object)}
+ *
+ */
+public final class EVCacheTranscoderProperties {
+
+ public static final boolean DEFAULT_BINARY_SERIALIZATION_ENABLED = false;
+ public static final int DEFAULT_MAX_DATA_SIZE_BYTES = 20 * 1024 * 1024;
+ public static final int DEFAULT_COMPRESSION_THRESHOLD_BYTES = 120;
+
+ public enum Key {
+ BINARY_SERIALIZATION_ENABLED("binary.serialization.enabled", "default.evcache.binary.serialization.enabled"),
+ MAX_DATA_SIZE_BYTES("max.data.size", "default.evcache.max.data.size"),
+ COMPRESSION_THRESHOLD_BYTES("compression.threshold", "default.evcache.compression.threshold");
+
+ final String appKeySuffix;
+ final String globalKey;
+
+ Key(String appKeySuffix, String globalKey) {
+ this.appKeySuffix = appKeySuffix;
+ this.globalKey = globalKey;
+ }
+ }
+
+ private final String appName;
+ private final PropertyRepository propertyRepository;
+
+ private final boolean binarySerializationEnabled;
+
+ /**
+ * Construct the bundle and snapshot every property via the three-level resolution chain.
+ *
+ * @param appName the EVCache app name used as the per-app override prefix
+ * (e.g. {@code "EVCACHE_FOO"}). When {@code null} or empty the
+ * per-app step is skipped and resolution starts at the global
+ * key — useful for the no-app transcoder constructors and for
+ * callers that only want fleet-wide defaults.
+ * @param propertyRepository the Archaius2 PropertyRepository to resolve against. Never null;
+ * pass {@code EVCacheConfig.getInstance().getPropertyRepository()}
+ * for the production wiring.
+ */
+ public EVCacheTranscoderProperties(String appName, PropertyRepository propertyRepository) {
+ this.appName = appName;
+ this.propertyRepository = propertyRepository;
+ this.binarySerializationEnabled = getProperty(appName, propertyRepository,
+ BINARY_SERIALIZATION_ENABLED, Boolean.class, DEFAULT_BINARY_SERIALIZATION_ENABLED).get();
+ }
+
+ public boolean isBinarySerializationEnabled() {
+ return binarySerializationEnabled;
+ }
+
+ /**
+ * Resolve the Archaius {@link Property} handle for the given key. Callers should hold the
+ * handle (as a final field, typically) and invoke {@link Property#get()} when they need the
+ * current value; every {@code .get()} re-reads through the same per-app → global → static-default
+ * chain, so live FP updates propagate without re-resolving. Returning the handle rather than
+ * the resolved value makes it obvious that this is a dynamic property, not a static snapshot.
+ */
+ public Additive optional (non-breaking). Append a new field at the end of the envelope,
+ * after {@code createTime}. Older readers stop after the known fields and never look at the
+ * extension bytes. Newer readers MUST gate each added field with {@code buffer.hasRemaining()}
+ * and supply a default when absent — they will encounter items written by old writers
+ * (in cache until TTL expires) that don't contain the field. Only works when a graceful
+ * default exists. A new required field has no acceptable default and is therefore
+ * Breaking, not additive.
+ *
+ * Breaking (field reorder, type widen, semantic change, new required field):
+ * rollout MUST be reader-before-writer — items written by an early writer would be
+ * silently misparsed by lagging readers and survive until TTL.
+ *
+ * [byte 0: magic 0x0C][byte 1: reserved/version 0x00]
+ * [int keyLen][key UTF-8 bytes]
+ * [int valLen][value bytes]
+ * [int flags][long ttl][long createTime]
+ * [... optional extension fields appended by newer writers ...]
+ *
+ *
+ *
+ *
+ *
+ * Upgrades
+ *
+ *
+ *
+ */
+public final class EVCacheValueSerde {
+
+ private static final Logger log = LoggerFactory.getLogger(EVCacheValueSerde.class);
+
+ static final byte BINARY_SERDE_MAGIC_CONSTANT_BYTE = 0x0C; // 12
+ private static final byte RESERVED_VERSION_BYTE = 0x00;
+
+ private static final int CORRUPT_PAYLOAD_LOG_LIMIT = 1024;
+
+ private EVCacheValueSerde() {
+ // Utility class; not instantiable.
+ }
+
+ /** True iff {@code bytes} starts with the binary envelope magic byte. */
+ public static boolean isBinaryFormat(byte[] bytes) {
+ return bytes != null && bytes.length > 0 && bytes[0] == BINARY_SERDE_MAGIC_CONSTANT_BYTE;
+ }
+
+ /**
+ * Encode an {@link EVCacheValue} into its compact binary envelope. Key and value must be
+ * non-null — the {@link com.netflix.evcache.EVCacheTranscoder} / {@code CachedData} pipeline
+ * above already rejects nulls.
+ */
+ public static byte[] serialize(EVCacheValue v) {
+ final byte[] keyBytes = v.getKey().getBytes(StandardCharsets.UTF_8);
+ final byte[] valueBytes = v.getValue();
+
+ final int bufferSize =
+ Byte.BYTES + Byte.BYTES // magic + reserved/version
+ + Integer.BYTES + keyBytes.length // keyLen + key
+ + Integer.BYTES + valueBytes.length // valLen + value
+ + Integer.BYTES // flags
+ + Long.BYTES // ttl
+ + Long.BYTES; // createTime
+ final ByteBuffer buffer = ByteBuffer.allocate(bufferSize).order(ByteOrder.BIG_ENDIAN);
+
+ buffer.put(BINARY_SERDE_MAGIC_CONSTANT_BYTE);
+ buffer.put(RESERVED_VERSION_BYTE);
+
+ buffer.putInt(keyBytes.length);
+ buffer.put(keyBytes);
+ buffer.putInt(valueBytes.length);
+ buffer.put(valueBytes);
+ buffer.putInt(v.getFlags());
+ buffer.putLong(v.getTTL());
+ buffer.putLong(v.getCreateTimeUTC());
+
+ return buffer.array();
+ }
+
+ /**
+ * Decode the binary envelope. Length prefixes are bounds-checked before allocation. A
+ * truncated or malformed payload returns {@code null} after a WARN log identifying the
+ * failing field. Bytes remaining past the known fields are not read — they're reserved for
+ * additive extension fields appended by newer writers (see Upgrades).
+ */
+ public static EVCacheValue deserialize(byte[] bytes) {
+ String field = "magic";
+ try {
+ final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
+
+ final byte magic = buffer.get();
+ if (BINARY_SERDE_MAGIC_CONSTANT_BYTE != magic) {
+ logCorruption(bytes, "Invalid magic constant: " + magic);
+ return null;
+ }
+ field = "reserved";
+ buffer.get();
+
+ field = "keyLength";
+ final int keyLength = buffer.getInt();
+ if (keyLength < 0 || keyLength > buffer.remaining()) {
+ logCorruption(bytes, "Invalid keyLength: " + keyLength + ", remaining=" + buffer.remaining());
+ return null;
+ }
+ field = "key";
+ final byte[] keyBytes = new byte[keyLength];
+ buffer.get(keyBytes);
+ final String key = new String(keyBytes, StandardCharsets.UTF_8);
+
+ field = "valueLength";
+ final int valueLength = buffer.getInt();
+ if (valueLength < 0 || valueLength > buffer.remaining()) {
+ logCorruption(bytes, "Invalid valueLength: " + valueLength + ", remaining=" + buffer.remaining());
+ return null;
+ }
+ field = "value";
+ final byte[] valueBytes = new byte[valueLength];
+ buffer.get(valueBytes);
+
+ field = "flags";
+ final int flags = buffer.getInt();
+ field = "ttl";
+ final long ttl = buffer.getLong();
+ field = "createTime";
+ final long createTime = buffer.getLong();
+
+ // Any remaining bytes are forward-compat extension fields a newer writer appended;
+ // an older reader (this one) leaves them unread.
+
+ return new EVCacheValue(key, valueBytes, flags, ttl, createTime);
+ } catch (BufferUnderflowException e) {
+ logCorruption(bytes, "BufferUnderflow at field '" + field + "'");
+ return null;
+ } catch (Exception e) {
+ log.warn("Uncaught exception decoding {} bytes of EVCacheValue binary envelope at field '{}'",
+ bytes.length, field, e);
+ return null;
+ }
+ }
+
+ /**
+ * Warn-log a corruption event with byte length, failure reason, and a (truncated) hex dump.
+ * No Throwable — corruption is expected/recoverable at WARN level; a stack trace would be
+ * noise. Hex capped at {@value #CORRUPT_PAYLOAD_LOG_LIMIT} bytes.
+ */
+ private static void logCorruption(byte[] bytes, String error) {
+ log.warn("Failed to deserialize {} bytes of EVCacheValue binary envelope, error={}, payload hex: {}",
+ bytes.length, error, toHex(bytes, CORRUPT_PAYLOAD_LOG_LIMIT));
+ }
+
+ private static String toHex(byte[] bytes, int maxBytes) {
+ if (bytes == null) {
+ return "null";
+ }
+ if (bytes.length <= maxBytes) {
+ return Hex.encodeHexString(bytes);
+ }
+ return Hex.encodeHexString(Arrays.copyOf(bytes, maxBytes))
+ + "...(truncated, total=" + bytes.length + " bytes)";
+ }
+}
diff --git a/evcache-core/src/test/java/com/netflix/evcache/config/EVCacheTranscoderPropertiesTest.java b/evcache-core/src/test/java/com/netflix/evcache/config/EVCacheTranscoderPropertiesTest.java
new file mode 100644
index 00000000..1754c6c1
--- /dev/null
+++ b/evcache-core/src/test/java/com/netflix/evcache/config/EVCacheTranscoderPropertiesTest.java
@@ -0,0 +1,167 @@
+package com.netflix.evcache.config;
+
+import static com.netflix.evcache.config.EVCacheTranscoderProperties.Key.COMPRESSION_THRESHOLD_BYTES;
+import static com.netflix.evcache.config.EVCacheTranscoderProperties.Key.MAX_DATA_SIZE_BYTES;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.netflix.archaius.DefaultPropertyFactory;
+import com.netflix.archaius.api.PropertyRepository;
+import com.netflix.archaius.config.DefaultSettableConfig;
+
+import org.testng.annotations.Test;
+
+/**
+ * Three-level resolution tests for each key in {@link EVCacheTranscoderProperties}:
+ * per-app override → global default → static default. Property keys are asserted as
+ * string literals so a rename of a {@link EVCacheTranscoderProperties.Key} entry
+ * trips these tests loudly.
+ */
+public class EVCacheTranscoderPropertiesTest {
+
+ private static final String APP = "MYAPP";
+ private static final String BINARY_PER_APP_KEY = "MYAPP.binary.serialization.enabled";
+ private static final String BINARY_GLOBAL_KEY = "default.evcache.binary.serialization.enabled";
+ private static final String MAX_DATA_SIZE_PER_APP_KEY = "MYAPP.max.data.size";
+ private static final String MAX_DATA_SIZE_GLOBAL_KEY = "default.evcache.max.data.size";
+ private static final String COMPRESSION_PER_APP_KEY = "MYAPP.compression.threshold";
+ private static final String COMPRESSION_GLOBAL_KEY = "default.evcache.compression.threshold";
+
+ private static PropertyRepository repo(DefaultSettableConfig cfg) {
+ return DefaultPropertyFactory.from(cfg);
+ }
+
+ // ---- BINARY_SERIALIZATION_ENABLED ----
+
+ @Test
+ public void binarySerialization_perAppOverrideWins() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(BINARY_PER_APP_KEY, "true");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.isBinarySerializationEnabled()).isTrue();
+ }
+
+ @Test
+ public void binarySerialization_globalFallbackWhenPerAppUnset() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(BINARY_GLOBAL_KEY, "true");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.isBinarySerializationEnabled()).isTrue();
+ }
+
+ @Test
+ public void binarySerialization_staticDefaultWhenBothUnset() {
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(new DefaultSettableConfig()));
+ assertThat(props.isBinarySerializationEnabled()).isFalse();
+ }
+
+ @Test
+ public void binarySerialization_perAppBeatsGlobal() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(BINARY_PER_APP_KEY, "false");
+ cfg.setProperty(BINARY_GLOBAL_KEY, "true");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.isBinarySerializationEnabled()).isFalse();
+ }
+
+ @Test
+ public void binarySerialization_nullAppNameUsesGlobalKey() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(BINARY_GLOBAL_KEY, "true");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(null, repo(cfg));
+ assertThat(props.isBinarySerializationEnabled()).isTrue();
+ }
+
+ // ---- MAX_DATA_SIZE ----
+
+ @Test
+ public void maxDataSize_perAppOverrideWins() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(MAX_DATA_SIZE_PER_APP_KEY, "12345");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, 999).get()).isEqualTo(12345);
+ }
+
+ @Test
+ public void maxDataSize_globalFallbackWhenPerAppUnset() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(MAX_DATA_SIZE_GLOBAL_KEY, "12345");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, 999).get()).isEqualTo(12345);
+ }
+
+ @Test
+ public void maxDataSize_staticDefaultWhenBothUnset() {
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(new DefaultSettableConfig()));
+ assertThat(props.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, 999).get()).isEqualTo(999);
+ }
+
+ @Test
+ public void maxDataSize_perAppBeatsGlobal() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(MAX_DATA_SIZE_PER_APP_KEY, "111");
+ cfg.setProperty(MAX_DATA_SIZE_GLOBAL_KEY, "222");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, 999).get()).isEqualTo(111);
+ }
+
+ @Test
+ public void maxDataSize_nullAppNameUsesGlobalKey() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(MAX_DATA_SIZE_GLOBAL_KEY, "12345");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(null, repo(cfg));
+ assertThat(props.getProperty(MAX_DATA_SIZE_BYTES, Integer.class, 999).get()).isEqualTo(12345);
+ }
+
+ // ---- COMPRESSION_THRESHOLD ----
+
+ @Test
+ public void compressionThreshold_perAppOverrideWins() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(COMPRESSION_PER_APP_KEY, "512");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999).get()).isEqualTo(512);
+ }
+
+ @Test
+ public void compressionThreshold_globalFallbackWhenPerAppUnset() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(COMPRESSION_GLOBAL_KEY, "512");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999).get()).isEqualTo(512);
+ }
+
+ @Test
+ public void compressionThreshold_staticDefaultWhenBothUnset() {
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(new DefaultSettableConfig()));
+ assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999).get()).isEqualTo(999);
+ }
+
+ @Test
+ public void compressionThreshold_perAppBeatsGlobal() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(COMPRESSION_PER_APP_KEY, "111");
+ cfg.setProperty(COMPRESSION_GLOBAL_KEY, "222");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(cfg));
+ assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999).get()).isEqualTo(111);
+ }
+
+ @Test
+ public void compressionThreshold_nullAppNameUsesGlobalKey() {
+ DefaultSettableConfig cfg = new DefaultSettableConfig();
+ cfg.setProperty(COMPRESSION_GLOBAL_KEY, "512");
+
+ EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(null, repo(cfg));
+ assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999).get()).isEqualTo(512);
+ }
+}
diff --git a/evcache-core/src/test/java/com/netflix/evcache/pool/EVCacheValueSerdeTest.java b/evcache-core/src/test/java/com/netflix/evcache/pool/EVCacheValueSerdeTest.java
new file mode 100644
index 00000000..02d6e271
--- /dev/null
+++ b/evcache-core/src/test/java/com/netflix/evcache/pool/EVCacheValueSerdeTest.java
@@ -0,0 +1,305 @@
+package com.netflix.evcache.pool;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import com.netflix.evcache.config.EVCacheTranscoderProperties;
+import org.testng.annotations.Test;
+
+import com.netflix.evcache.EVCacheTranscoder;
+
+import net.spy.memcached.CachedData;
+
+/**
+ * Pure unit tests for the compact binary serialization of {@link EVCacheValue} (the
+ * envelope wire format implemented inside {@link EVCacheTranscoder}), its routing through
+ * the transcoder, and backwards-compatibility with the legacy Java-serialized format.
+ * All tests go through the public {@link EVCacheTranscoder#encode(Object)} /
+ * {@link EVCacheTranscoder#decode(CachedData)} API — the binary codec itself is a private
+ * implementation detail of {@link EVCacheTranscoder}. No memcached, no DI.
+ */
+public class EVCacheValueSerdeTest {
+
+ private static final int SERIALIZED = 1; // EVCacheSerializingTranscoder.SERIALIZED
+ private static final byte JAVA_STREAM_MAGIC_FIRST = (byte) 0xAC;
+ private static final byte JAVA_STREAM_MAGIC_SECOND = (byte) 0xED;
+
+ // ---- helpers ----
+
+ /** Binary-enabled transcoder, compression disabled, so encoded bytes start with our magic. */
+ private static EVCacheTranscoder binaryTranscoder() {
+ com.netflix.archaius.config.DefaultSettableConfig cfg = new com.netflix.archaius.config.DefaultSettableConfig();
+ cfg.setProperty("testApp.binary.serialization.enabled", "true");
+ cfg.setProperty("testApp.compression.threshold", String.valueOf(Integer.MAX_VALUE));
+
+ return new EVCacheTranscoder(
+ new EVCacheTranscoderProperties("testApp",
+ com.netflix.archaius.DefaultPropertyFactory.from(cfg)));
+ }
+
+ /** Default transcoder (binary OFF, falls through to native Java serialization). */
+ private static EVCacheTranscoder defaultTranscoder() {
+ return new EVCacheTranscoder(EVCacheTranscoderProperties.DEFAULT_MAX_DATA_SIZE_BYTES, Integer.MAX_VALUE);
+ }
+
+ private EVCacheValue value(String key, byte[] val, int flags, long ttl, long createTime) {
+ return new EVCacheValue(key, val, flags, ttl, createTime);
+ }
+
+ private EVCacheValue typical() {
+ return value("myKey", "hello world".getBytes(StandardCharsets.UTF_8), 0, 3600L, 1_700_000_000_000L);
+ }
+
+ /** Serialize an object the legacy way an old client would: java.io ObjectOutputStream. */
+ private byte[] javaSerialize(Object o) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(o);
+ }
+ return baos.toByteArray();
+ }
+
+ private int javaSerializedLength(EVCacheValue v) throws Exception {
+ return javaSerialize(v).length;
+ }
+
+ /** End-to-end round-trip via the public transcoder API with binary serialization enabled. */
+ private void assertBinaryRoundTrip(EVCacheValue v) {
+ EVCacheTranscoder t = binaryTranscoder();
+ CachedData cd = t.encode(v);
+ // Sanity: actually binary-encoded.
+ assertThat(cd.getData()[0]).isEqualTo(EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE);
+ EVCacheValue out = (EVCacheValue) t.decode(cd);
+ assertThat(out).isEqualTo(v);
+ }
+
+ // ---- 1. Binary round-trip across cases (via transcoder) ----
+
+ @Test
+ public void testBinaryRoundTripEmptyValue() {
+ assertBinaryRoundTrip(value("k", new byte[0], 0, 100L, 1L));
+ }
+
+ @Test
+ public void testBinaryRoundTripLargeValue() {
+ byte[] large = new byte[2 * 1024 * 1024];
+ for (int i = 0; i < large.length; i++) {
+ large[i] = (byte) (i & 0xFF);
+ }
+ assertBinaryRoundTrip(value("largeKey", large, 2, 86400L, 1_700_000_000_000L));
+ }
+
+ @Test
+ public void testBinaryRoundTripUnicodeKey() {
+ assertBinaryRoundTrip(value("键🔑-é- key",
+ "payload".getBytes(StandardCharsets.UTF_8), 7, 60L, 42L));
+ }
+
+ @Test
+ public void testBinaryRoundTripZeroTtl() {
+ assertBinaryRoundTrip(value("zt", "v".getBytes(StandardCharsets.UTF_8), 1, 0L, 42L));
+ }
+
+ @Test
+ public void testBinaryRoundTripNegativeCreateTime() {
+ assertBinaryRoundTrip(value("nct", "v".getBytes(StandardCharsets.UTF_8), 1, 60L, -987654321L));
+ }
+
+ @Test
+ public void testBinaryRoundTripMaxCreateTime() {
+ assertBinaryRoundTrip(value("mct", "v".getBytes(StandardCharsets.UTF_8), 1, 60L, Long.MAX_VALUE));
+ }
+
+ @Test
+ public void testBinaryRoundTripMinFlags() {
+ assertBinaryRoundTrip(value("minf", "v".getBytes(StandardCharsets.UTF_8), Integer.MIN_VALUE, 60L, 42L));
+ }
+
+ // ---- 2. Transcoder produces expected wire shape (binary mode) ----
+
+ @Test
+ public void testTranscoderBinaryWireShape() {
+ EVCacheTranscoder t = binaryTranscoder();
+ EVCacheValue v = typical();
+
+ CachedData cd = t.encode(v);
+
+ // SERIALIZED flag must be set so decode routes through deserialize().
+ assertThat(cd.getFlags() & SERIALIZED).isNotZero();
+ // Binary envelope marker present (no compression interfering).
+ assertThat(cd.getData()[0]).isEqualTo(EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE);
+ // Byte index 1 is the reserved/version byte, currently always 0x00.
+ assertThat(cd.getData()[1]).isEqualTo((byte) 0x00);
+
+ Object out = t.decode(cd);
+ assertThat(out).isInstanceOf(EVCacheValue.class);
+ assertThat(out).isEqualTo(v);
+ }
+
+ // ---- 3. Default transcoder writes Java, but decode reads both formats ----
+
+ @Test
+ public void testTranscoderDefaultProducesJavaAndReadsBoth() {
+ EVCacheTranscoder t = defaultTranscoder();
+ EVCacheValue v = typical();
+
+ CachedData cd = t.encode(v);
+
+ // Java serialization stream magic is 0xAC 0xED.
+ byte[] data = cd.getData();
+ assertThat(data[0]).isEqualTo(JAVA_STREAM_MAGIC_FIRST);
+ assertThat(data[1]).isEqualTo(JAVA_STREAM_MAGIC_SECOND);
+ // SERIALIZED flag still set.
+ assertThat(cd.getFlags() & SERIALIZED).isNotZero();
+
+ // Dual-format read: default-Java write decodes back to an equal EVCacheValue.
+ Object out = t.decode(cd);
+ assertThat(out).isInstanceOf(EVCacheValue.class);
+ assertThat(out).isEqualTo(v);
+ }
+
+ // ---- 4. Backwards-compat: new client reads legacy Java-serialized bytes ----
+
+ @Test
+ public void testBackwardsCompatLegacyJavaSerialized() throws Exception {
+ EVCacheValue v = typical();
+ byte[] javaBytes = javaSerialize(v);
+
+ // Sanity: legacy bytes start with the Java stream header, not our binary magic.
+ assertThat(javaBytes[0]).isEqualTo(JAVA_STREAM_MAGIC_FIRST);
+ assertThat(javaBytes[0]).isNotEqualTo(EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE);
+
+ CachedData cd = new CachedData(SERIALIZED, javaBytes, CachedData.MAX_SIZE);
+ Object out = defaultTranscoder().decode(cd);
+
+ assertThat(out).isInstanceOf(EVCacheValue.class);
+ assertThat(out).isEqualTo(v);
+ }
+
+ // ---- 5. Non-EVCacheValue passthrough (arbitrary Java objects still use Java serde) ----
+
+ @Test
+ public void testNonEVCacheValuePassthrough() {
+ EVCacheTranscoder t = binaryTranscoder(); // even with binary on, non-EVCacheValue stays Java
+ ArrayList