From 59203c0eb6c632e41d578b5c8d9ca1fb0dc88108 Mon Sep 17 00:00:00 2001 From: Joe Lee Date: Thu, 2 Jul 2026 11:27:49 -0400 Subject: [PATCH] EVCacheValue: opt-in compact binary serialization with backwards-compatible reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hashed-key values are wrapped in an EVCacheValue envelope (key, value, flags, ttl, createTime) currently serialized with Java ObjectOutputStream, adding ~50-80 bytes of structural overhead per item. This adds a compact, length-prefixed binary format for the envelope while remaining fully backwards-compatible on reads, plus a first-class typed property bundle (EVCacheTranscoderProperties) that lives on the base transcoder and drives the transcoder's runtime behavior. ## Wire format [magic 0x0C][reserved 0x00][int keyLen][key UTF-8][int valLen][value] [int flags][long ttl][long createTime] - Leading magic byte disambiguates from Java's 0xAC 0xED. - Reserved byte at index 1 is read-and-ignored today; future readers can branch on it for backwards-compatible format changes. - Reads auto-detect format by leading byte: 0xAC 0xED -> Java, 0x0C -> binary. ## New classes - EVCacheValueSerde (com.netflix.evcache.pool): the codec. Bounds-checks length prefixes before allocating; corrupt payloads warn-log the failing field + truncated hex dump (via Apache Commons Hex.encodeHexString, capped at 1024 bytes) and return null. Matches BaseSerializingTranscoder's resilience contract: corruption -> cache miss -> caller refills. - EVCacheTranscoderProperties (com.netflix.evcache.config): typed access to the FastProperties that govern transcoder behavior. Three keys today (BINARY_SERIALIZATION_ENABLED, MAX_DATA_SIZE_BYTES, COMPRESSION_THRESHOLD_BYTES). The Key enum is the extension point for future keys. Per-app -> global -> static-default resolution chain; static value cached at construction for hot-path reads. ## EVCacheSerializingTranscoder integration The bundle is a protected final field on the base class: protected final EVCacheTranscoderProperties properties; Subclasses inherit one source of truth for FP resolution. Legacy constructors are preserved and internally build a null-appName bundle. ## Feature Property rollout gate Resolved through EVCacheTranscoderProperties with the three-level fallback: - Per-app: .binary.serialization.enabled - Global: default.evcache.binary.serialization.enabled - Default: false Read once at client construction and cached as a primitive on the (immutable) transcoder ==> deploy/restart required to take effect; NOT a live runtime toggle. Flip the property, then redeploy the consuming app. ## Compatibility - New clients decode existing Java-serialized values unchanged (dual-format read). - With the FP off (default), wire output is byte-identical to today. - Corrupt binary payloads degrade to cache miss (null), matching the Java path. - Public EVCacheTranscoder constructors (0-arg, int, int int) preserved. ## Testing - EVCacheValueSerdeTest — 17 cases via public transcoder API: binary round-trip edge cases, wire-shape assertions, backwards-compat with legacy Java bytes, non-EVCacheValue passthrough, size-win, malformed binary paths, forward-compat with optional additive fields. - EVCacheTranscoderPropertiesTest — 15 cases (5 per key) covering the three-level resolution chain. Property keys are asserted as string literals so an enum rename fails the tests loudly. - ./gradlew :evcache-core:test green. - Smoke-tested end-to-end against a locally-published snapshot in a Spring Boot Netflix sample app; round-trip put/get/delete on hashed keys passes. Chunked-payload integration remains covered by EVCacheClient's existing byte-level assembleChunks + CRC path; the binary format introduces no new chunking risk by construction. --- .../java/com/netflix/evcache/EVCacheImpl.java | 8 +- .../evcache/EVCacheSerializingTranscoder.java | 19 +- .../netflix/evcache/EVCacheTranscoder.java | 54 +++- .../config/EVCacheTranscoderProperties.java | 90 ++++++ .../evcache/pool/EVCacheValueSerde.java | 196 +++++++++++ .../EVCacheTranscoderPropertiesTest.java | 167 ++++++++++ .../evcache/pool/EVCacheValueSerdeTest.java | 305 ++++++++++++++++++ evcache-core/src/test/java/test-suite.xml | 2 + 8 files changed, 834 insertions(+), 7 deletions(-) create mode 100644 evcache-core/src/main/java/com/netflix/evcache/config/EVCacheTranscoderProperties.java create mode 100644 evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheValueSerde.java create mode 100644 evcache-core/src/test/java/com/netflix/evcache/config/EVCacheTranscoderPropertiesTest.java create mode 100644 evcache-core/src/test/java/com/netflix/evcache/pool/EVCacheValueSerdeTest.java 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..3fc86199 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), + properties.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, DEFAULT_COMPRESSION_THRESHOLD_BYTES), + 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), 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..35519b63 --- /dev/null +++ b/evcache-core/src/main/java/com/netflix/evcache/config/EVCacheTranscoderProperties.java @@ -0,0 +1,90 @@ +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: + * + *

    + *
  1. Per-app override: {@code .}
  2. + *
  3. Global default: {@code }
  4. + *
  5. Static default: default value supplied as an argument
  6. + *
+ * + *

+ * 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; + } + + /** + * Read a property dynamically (re-evaluates on every call), with the same per-app -> global -> + * static-default chain used for the cached fields above. + */ + public T getProperty(Key key, Class type, T defaultValue) { + return getProperty(appName, propertyRepository, key, type, defaultValue).get(); + } + + private static Property getProperty(String appName, PropertyRepository propertyRepository, + Key key, Class type, T defaultValue) { + if (appName == null || appName.isEmpty()) { + return propertyRepository.get(key.globalKey, type).orElse(defaultValue); + } + return propertyRepository.get(appName + "." + key.appKeySuffix, type) + .orElseGet(key.globalKey) + .orElse(defaultValue); + } +} diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheValueSerde.java b/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheValueSerde.java new file mode 100644 index 00000000..d65b4078 --- /dev/null +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheValueSerde.java @@ -0,0 +1,196 @@ +package com.netflix.evcache.pool; + +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.apache.commons.codec.binary.Hex; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Length-prefixed binary wire format for the {@link EVCacheValue} envelope. EVCache wraps a + * value in an {@code EVCacheValue} when the canonical key has to be hashed (see + * {@code EVCacheImpl.getEVCacheKey}) so the pre-hash key is preserved for collision detection. + * + *

+ * [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

+ * + *

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. + *

    + *
  1. Ship a version-aware reader that branches on byte 1: {@code 0x00} stays on the current + * decoder, the new value routes to the new decoder, unknown values + * {@link #logCorruption(byte[], String)} and return {@code null}. Deploy to 100% of every + * consumer that calls {@link #deserialize} (clients, admin tools, cache warmers, + * replicators).
  2. + *
  3. Wait for the longer of (full reader rollout) and (max item TTL).
  4. + *
  5. Then ship the new writer gated by a per-app FastProperty so canary is possible.
  6. + *
  7. Never reuse a version byte value for a different layout.
  8. + *
  9. Keep the old decoder path indefinitely — items live until their TTL expires.
  10. + *
+ */ +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..9fcb8ed0 --- /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)).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)).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)).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)).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)).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)).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)).isEqualTo(512); + } + + @Test + public void compressionThreshold_staticDefaultWhenBothUnset() { + EVCacheTranscoderProperties props = new EVCacheTranscoderProperties(APP, repo(new DefaultSettableConfig())); + assertThat(props.getProperty(COMPRESSION_THRESHOLD_BYTES, Integer.class, 999)).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)).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)).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 list = new ArrayList<>(); + list.add("a"); + list.add("b"); + list.add("c"); + + CachedData cd = t.encode(list); + Object out = t.decode(cd); + + assertThat(out).isEqualTo(list); + // Routed through generic Java serialization, not the binary envelope. + assertThat(cd.getFlags() & SERIALIZED).isNotZero(); + assertThat(cd.getData()[0]).isEqualTo(JAVA_STREAM_MAGIC_FIRST); + } + + // ---- 6. Size win: binary smaller than Java for a representative item ---- + + @Test + public void testBinaryIsSmallerThanJava() throws Exception { + EVCacheValue v = typical(); + int binaryLen = binaryTranscoder().encode(v).getData().length; + int javaLen = javaSerializedLength(v); + assertThat(binaryLen).isLessThan(javaLen); + } + + // ---- 7. Malformed binary input is logged in EVCacheValueSerde and decodes to null ---- + // + // EVCacheValueSerde.deserialize warn-logs the corruption (field + truncated hex) and returns + // null. Callers see a cache miss rather than a thrown exception, matching the resilience + // contract of BaseSerializingTranscoder. + + @Test + public void testDecodeTruncatedBinaryReturnsNull() { + byte[] full = binaryTranscoder().encode(typical()).getData(); + byte[] truncated = Arrays.copyOf(full, 3); + CachedData cd = new CachedData(SERIALIZED, truncated, CachedData.MAX_SIZE); + assertThat(defaultTranscoder().decode(cd)).isNull(); + } + + @Test + public void testDecodeBinaryWithBogusKeyLengthReturnsNull() { + // Magic + reserved + wildly oversized keyLength. Bounds check rejects. + byte[] bytes = new byte[2 + Integer.BYTES]; + bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE; + bytes[1] = 0x00; + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + bb.putInt(2, 0x7FFFFFFF); + CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE); + assertThat(defaultTranscoder().decode(cd)).isNull(); + } + + @Test + public void testDecodeBinaryWithNegativeKeyLengthReturnsNull() { + byte[] bytes = new byte[2 + Integer.BYTES]; + bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE; + bytes[1] = 0x00; + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + bb.putInt(2, -1); + CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE); + assertThat(defaultTranscoder().decode(cd)).isNull(); + } + + // ---- 8. Forward compatibility trip-wire: pinned v0 payload must always decode ---- + // + // If this test starts failing after a change to EVCacheValueSerde.deserialize(), someone + // likely added a required field without the `buffer.hasRemaining()` guard. See the + // "Additive optional" section of EVCacheValueSerde's Javadoc — a future reader must be + // able to decode the v0 payload below (which an old writer would have produced) for as + // long as items written by old writers can still be in any cache. + // + // The bytes here are intentionally FROZEN. Do not update them when adding fields. + @Test + public void testV0PayloadDecodesAsOptionalAdditiveFieldTripWire() { + byte[] v0Bytes = { + (byte) 0x0C, // magic + (byte) 0x00, // reserved/version + 0x00, 0x00, 0x00, 0x01, // keyLength = 1 + (byte) 'k', + 0x00, 0x00, 0x00, 0x01, // valueLength = 1 + 0x76, // value byte + 0x00, 0x00, 0x00, 0x01, // flags = 1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C, // ttl = 60 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A, // createTime = 42 + }; + CachedData cd = new CachedData(SERIALIZED, v0Bytes, CachedData.MAX_SIZE); + Object out = defaultTranscoder().decode(cd); + + EVCacheValue expected = new EVCacheValue("k", new byte[] {0x76}, 1, 60L, 42L); + assertThat(out) + .as("Pinned v0 payload must decode cleanly. If it doesn't, a required field was " + + "likely added to deserialize() without the buffer.hasRemaining() guard. " + + "See EVCacheValueSerde Javadoc 'Additive optional'.") + .isEqualTo(expected); + } + + // ---- 9. Forward compatibility: newer-writer extension bytes past createTime ---- + // + // A writer that adds new optional fields appends them after createTime. An older reader + // (this one) reads its known fields, leaves the extension bytes unread, and returns the + // EVCacheValue it does know how to decode — NOT a corruption event. + + @Test + public void testDecodeBinaryWithFutureExtensionFieldsIsForwardCompat() { + EVCacheValue v = typical(); + byte[] validBytes = binaryTranscoder().encode(v).getData(); + + // Append 3 extension bytes past the end of the v0 envelope — what a future writer + // would do. End of envelope is implicit at bytes.length, no header to update. + byte[] withExtension = Arrays.copyOf(validBytes, validBytes.length + 3); + + CachedData cd = new CachedData(SERIALIZED, withExtension, CachedData.MAX_SIZE); + Object out = defaultTranscoder().decode(cd); + assertThat(out).isInstanceOf(EVCacheValue.class); + assertThat(out).isEqualTo(v); + } +} diff --git a/evcache-core/src/test/java/test-suite.xml b/evcache-core/src/test/java/test-suite.xml index f031a615..e31a08d4 100644 --- a/evcache-core/src/test/java/test-suite.xml +++ b/evcache-core/src/test/java/test-suite.xml @@ -3,6 +3,8 @@ + +