Skip to content

Commit 3444f24

Browse files
Joe Leeclaude
andcommitted
EVCacheValue: opt-in compact binary serde with backwards-compatible read
The hashed-key EVCacheValue envelope is serialized with Java ObjectOutputStream, adding ~50-80 bytes/item. Add a compact length-prefixed binary format and have EVCacheTranscoder override serialize()/deserialize() so super.encode() still sets the SERIALIZED flag and CachedData as before. - Binary writing is OPT-IN and OFF BY DEFAULT (EVCacheTranscoder.setUseBinarySerialization); by default EVCacheValue is still Java-serialized. This is the backwards-compat gate: readers must ship the decode change before any writer enables binary. - Reads always auto-detect by leading byte (0xAC ED = legacy Java, 0x0C = binary), so a client with this change decodes existing Java-serialized values unchanged. - Wire format reserves a version byte (0x00) after the magic byte for future breaking changes; versioning is not implemented yet (documented as reserved). Tests: EVCacheValueSerdeTest (17 cases) — binary round-trip, transcoder encode/decode with the flag on, default-off (Java) write + dual-format read, legacy-Java backwards-compat decode, non-EVCacheValue passthrough, magic/reserved-byte dispatch, size win, malformed input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa8721e commit 3444f24

5 files changed

Lines changed: 484 additions & 2 deletions

File tree

evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ public class EVCacheImpl implements EVCache, EVCacheImplMBean {
7777

7878
private static final Logger log = LoggerFactory.getLogger(EVCacheImpl.class);
7979

80+
// The envelope transcoder used for hashed-key EVCacheValue wrapping must NOT compress its output:
81+
// reads detect the format by the leading byte (0xAC = legacy Java, 0x0C = compact binary), which
82+
// gzip would mask (compressed payloads start with 0x1F 0x8B). Disable by setting the threshold
83+
// higher than any plausible value size.
84+
private static final int ENVELOPE_COMPRESSION_DISABLED = Integer.MAX_VALUE;
85+
8086
private final Clock clock;
8187
private final String _appName;
8288
private final String _cacheName;
@@ -164,8 +170,12 @@ public class EVCacheImpl implements EVCache, EVCacheImplMBean {
164170
this.maxHashLength = propertyRepository.get(appName + ".max.hash.length", Integer.class).orElse(-1);
165171
this.encoderBase = propertyRepository.get(appName + ".hash.encoder", String.class).orElse("base64");
166172
this.autoHashKeys = propertyRepository.get(_appName + ".auto.hash.keys", Boolean.class).orElseGet("evcache.auto.hash.keys").orElse(false);
167-
this.evcacheValueTranscoder = new EVCacheTranscoder();
168-
evcacheValueTranscoder.setCompressionThreshold(Integer.MAX_VALUE);
173+
// Whether the EVCacheValue envelope (hashed keys) is written using the compact binary format
174+
// instead of native Java serialization.
175+
final boolean useBinarySerialization = propertyRepository.get(_appName + ".envelope.binary.serialization.enabled", Boolean.class)
176+
.orElseGet("evcache.envelope.binary.serialization.enabled").orElse(false).get();
177+
final int maxValueSize = propertyRepository.get("default.evcache.max.data.size", Integer.class).orElse(20 * 1024 * 1024).get();
178+
this.evcacheValueTranscoder = new EVCacheTranscoder(maxValueSize, ENVELOPE_COMPRESSION_DISABLED, useBinarySerialization);
169179

170180
// default max key length is 200, instead of using what is defined in MemcachedClientIF.MAX_KEY_LENGTH (250). This is to accommodate
171181
// auto key prepend with appname for duet feature.

evcache-core/src/main/java/com/netflix/evcache/EVCacheTranscoder.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.netflix.evcache;
22

3+
import com.netflix.evcache.pool.EVCacheValue;
4+
import com.netflix.evcache.pool.EVCacheValueSerde;
35
import com.netflix.evcache.util.EVCacheConfig;
46

57
import net.spy.memcached.CachedData;
68

79
public class EVCacheTranscoder extends EVCacheSerializingTranscoder {
810

11+
private final boolean useBinarySerialization;
12+
913
public EVCacheTranscoder() {
1014
this(EVCacheConfig.getInstance().getPropertyRepository().get("default.evcache.max.data.size", Integer.class).orElse(20 * 1024 * 1024).get());
1115
}
@@ -15,8 +19,13 @@ public EVCacheTranscoder(int max) {
1519
}
1620

1721
public EVCacheTranscoder(int max, int compressionThreshold) {
22+
this(max, compressionThreshold, false);
23+
}
24+
25+
public EVCacheTranscoder(int max, int compressionThreshold, boolean useBinarySerialization) {
1826
super(max);
1927
setCompressionThreshold(compressionThreshold);
28+
this.useBinarySerialization = useBinarySerialization;
2029
}
2130

2231
@Override
@@ -35,4 +44,20 @@ public CachedData encode(Object o) {
3544
return super.encode(o);
3645
}
3746

47+
@Override
48+
protected byte[] serialize(Object o) {
49+
if (useBinarySerialization && o instanceof EVCacheValue) {
50+
return EVCacheValueSerde.serialize((EVCacheValue) o);
51+
}
52+
return super.serialize(o);
53+
}
54+
55+
@Override
56+
protected Object deserialize(byte[] in) {
57+
if (EVCacheValueSerde.isBinaryFormat(in)) {
58+
return EVCacheValueSerde.deserialize(in);
59+
}
60+
return super.deserialize(in);
61+
}
62+
3863
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.netflix.evcache.pool;
2+
3+
import java.nio.BufferUnderflowException;
4+
import java.nio.ByteBuffer;
5+
import java.nio.ByteOrder;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.Arrays;
8+
9+
import org.apache.commons.codec.binary.Hex;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
/**
14+
* Length-prefixed binary wire format for the {@link EVCacheValue} envelope. EVCache wraps the
15+
* user's value in an {@link EVCacheValue} when the cache key needs to be hashed — typically
16+
* because the canonical key would exceed memcached's key-length limit (auto-hashing path) or
17+
* because the app opted into hashing explicitly (see {@code EVCacheImpl.getEVCacheKey}). The
18+
* envelope carries the original (pre-hash) key so it can be recovered from the value to detect
19+
* hash collisions on read. The on-the-wire layout is:
20+
* <pre>
21+
* [byte 0: magic 0x0C][byte 1: reserved/version 0x00]
22+
* [int keyLen][key UTF-8 bytes]
23+
* [int valLen][value bytes]
24+
* [int flags][long ttl][long createTime]
25+
* </pre>
26+
*
27+
* <p>Byte 0 (magic {@code 0x0C}) discriminates this format from the legacy Java
28+
* {@code ObjectOutputStream} stream header ({@code 0xAC 0xED}), so both formats can coexist
29+
* under the same {@code SERIALIZED} CachedData flag. Callers use {@link #isBinaryFormat(byte[])}
30+
* to decide between this codec and the legacy Java path.
31+
*
32+
* <p>Byte 1 is RESERVED for future breaking changes to the wire format. It is currently always {@code 0x00}.
33+
* Versioning is intentionally NOT implemented yet, and the reader read-and-ignores this byte
34+
* (it does NOT validate that it equals {@code 0x00}).
35+
*
36+
* <p><b>Byte order:</b> all multi-byte integer fields ({@code keyLen}, {@code valLen},
37+
* {@code flags}, {@code ttl}, {@code createTime}) are encoded in <b>big-endian / network byte
38+
* order</b>. This matches Java {@link ByteBuffer}'s default and is set explicitly on both the
39+
* writer and the reader so a future change to that default cannot silently corrupt the wire
40+
* format. Any non-JVM reader of these bytes must use big-endian decoding.
41+
*/
42+
public final class EVCacheValueSerde {
43+
44+
private static final Logger log = LoggerFactory.getLogger(EVCacheValueSerde.class);
45+
46+
static final byte BINARY_SERDE_MAGIC_CONSTANT_BYTE = 0x0C; // 12
47+
private static final byte RESERVED_VERSION_BYTE = 0x00;
48+
49+
private static final int CORRUPT_PAYLOAD_LOG_LIMIT = 1024;
50+
51+
private EVCacheValueSerde() {
52+
// Utility class; not instantiable.
53+
}
54+
55+
/** True iff {@code bytes} starts with the binary envelope magic byte. */
56+
public static boolean isBinaryFormat(byte[] bytes) {
57+
return bytes != null && bytes.length > 0 && bytes[0] == BINARY_SERDE_MAGIC_CONSTANT_BYTE;
58+
}
59+
60+
/**
61+
* Encode an {@link EVCacheValue} into its compact binary envelope. See class Javadoc for layout.
62+
*
63+
* <p>The {@code EVCacheValue}'s key and value must be non-null. Production writes can only
64+
* reach this method via {@link com.netflix.evcache.EVCacheTranscoder}/{@code CachedData},
65+
* which both reject null payloads upstream — so this method does not defensively check.
66+
*/
67+
public static byte[] serialize(EVCacheValue v) {
68+
final byte[] keyBytes = v.getKey().getBytes(StandardCharsets.UTF_8);
69+
final byte[] valueBytes = v.getValue();
70+
71+
final int bufferSize = Byte.BYTES + // magic byte
72+
Byte.BYTES + // reserved/version byte
73+
Integer.BYTES + keyBytes.length + // keyLen + key
74+
Integer.BYTES + valueBytes.length + // valLen + value
75+
Integer.BYTES + // flags
76+
Long.BYTES + // ttl
77+
Long.BYTES; // createTime
78+
79+
final ByteBuffer buffer = ByteBuffer.allocate(bufferSize).order(ByteOrder.BIG_ENDIAN);
80+
81+
buffer.put(BINARY_SERDE_MAGIC_CONSTANT_BYTE);
82+
buffer.put(RESERVED_VERSION_BYTE);
83+
84+
buffer.putInt(keyBytes.length);
85+
buffer.put(keyBytes);
86+
87+
buffer.putInt(valueBytes.length);
88+
buffer.put(valueBytes);
89+
90+
buffer.putInt(v.getFlags());
91+
buffer.putLong(v.getTTL());
92+
buffer.putLong(v.getCreateTimeUTC());
93+
94+
return buffer.array();
95+
}
96+
97+
/**
98+
* Deserializes bytes into {@link EVCacheValue} from custom wire format.
99+
*
100+
* <p>Error behavior: on any corrupt or truncated payload (failed bounds check, buffer
101+
* underflow, or any other unexpected exception) this method warn-logs the field that failed
102+
* and a truncated hex dump of the source bytes, then returns {@code null}. The caller sees a
103+
* cache miss rather than a thrown exception, matching {@code BaseSerializingTranscoder}'s
104+
* resilience contract.
105+
*
106+
* <p>Length prefixes are bounds-checked against the remaining buffer before allocating, so a
107+
* malformed length prefix is rejected before any huge allocation or
108+
* {@link NegativeArraySizeException}.
109+
*/
110+
public static EVCacheValue deserialize(byte[] bytes) {
111+
String field = "magic";
112+
try {
113+
final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
114+
115+
final byte magic = buffer.get();
116+
if (BINARY_SERDE_MAGIC_CONSTANT_BYTE != magic) {
117+
logCorruption(bytes, "Invalid magic constant: " + magic);
118+
return null;
119+
}
120+
// Reserved/version byte: read-and-ignore (see class Javadoc).
121+
field = "reserved";
122+
buffer.get();
123+
124+
field = "keyLength";
125+
final int keyLength = buffer.getInt();
126+
if (keyLength < 0 || keyLength > buffer.remaining()) {
127+
logCorruption(bytes,
128+
"Invalid keyLength: " + keyLength + ", remaining=" + buffer.remaining());
129+
return null;
130+
}
131+
field = "key";
132+
final byte[] keyBytes = new byte[keyLength];
133+
buffer.get(keyBytes);
134+
final String key = new String(keyBytes, StandardCharsets.UTF_8);
135+
136+
field = "valueLength";
137+
final int valueLength = buffer.getInt();
138+
if (valueLength < 0 || valueLength > buffer.remaining()) {
139+
logCorruption(bytes,
140+
"Invalid valueLength: " + valueLength + ", remaining=" + buffer.remaining());
141+
return null;
142+
}
143+
field = "value";
144+
final byte[] valueBytes = new byte[valueLength];
145+
buffer.get(valueBytes);
146+
147+
field = "flags";
148+
final int flags = buffer.getInt();
149+
field = "ttl";
150+
final long ttl = buffer.getLong();
151+
field = "createTime";
152+
final long createTime = buffer.getLong();
153+
154+
return new EVCacheValue(key, valueBytes, flags, ttl, createTime);
155+
} catch (BufferUnderflowException e) {
156+
logCorruption(bytes, "BufferUnderflow at field '" + field + "'");
157+
return null;
158+
} catch (Exception e) {
159+
// Defensive catch-all for any unexpected exception
160+
log.warn("Uncaught exception decoding {} bytes of EVCacheValue binary envelope at field '{}'",
161+
bytes.length, field, e);
162+
return null;
163+
}
164+
}
165+
166+
/**
167+
* Warn-log a corruption event with the source byte length, the failure reason, and a hex
168+
* dump of the payload. We deliberately do not pass a Throwable as an SLF4J argument because
169+
* data corruption is an expected/recoverable condition at WARN level; a full stack trace
170+
* would be noise. The hex dump is capped at {@value #CORRUPT_PAYLOAD_LOG_LIMIT} bytes
171+
* (with a truncation marker appended) to keep log spam bounded for very large corrupt
172+
* payloads while preserving enough context to triage.
173+
*/
174+
private static void logCorruption(byte[] bytes, String error) {
175+
log.warn("Failed to deserialize {} bytes of EVCacheValue binary envelope, error={}, payload hex: {}",
176+
bytes.length, error, toHex(bytes, CORRUPT_PAYLOAD_LOG_LIMIT));
177+
}
178+
179+
private static String toHex(byte[] bytes, int maxBytes) {
180+
if (bytes == null) {
181+
return "null";
182+
}
183+
if (bytes.length <= maxBytes) {
184+
return Hex.encodeHexString(bytes);
185+
}
186+
return Hex.encodeHexString(Arrays.copyOf(bytes, maxBytes))
187+
+ "...(truncated, total=" + bytes.length + " bytes)";
188+
}
189+
}

0 commit comments

Comments
 (0)