Skip to content

Commit 75d8249

Browse files
Joe Leeclaude
andcommitted
EVCacheValueSerde: drop bodyLength from the wire format
The bodyLength field was meant to serve two purposes — additive forward-compat (skip extension bytes past the known fields) and corruption detection (reject trailing bytes). Those goals conflict: extension bytes and under-framed length prefixes are observationally identical to the reader, so the design could only catch one and ended up catching the cosmetic one — trailing bytes the serializer cannot produce by construction — while letting length-prefix under-framing slip through unchecked. Removing bodyLength entirely. End-of-envelope is now implicit at bytes.length. Newer writers append optional fields after createTime; older readers stop after the known fields and leave any remaining bytes unread. Required fields still need the version-byte rollout documented in the class javadoc. Tests: the two forward-compat tests (v0 pinned-payload trip-wire and future-extension-bytes) refrozen against the simpler format and both pass; the bodyLength-specific corruption tests are removed (testDecodeBinaryWithBogusBodyLengthReturnsNull, testDecodeBinaryWithNegativeBodyLengthReturnsNull, testDecodeBinaryWithTrailingBytesBeyondBodyReturnsNull). The remaining bounds-check tests are simplified to drop the no-longer-present bodyLength prefix from their crafted bytes. Bit-corruption detection for value bytes / length-prefix tampering is deferred to a follow-up — it will not be bodyLength. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ca789b commit 75d8249

2 files changed

Lines changed: 40 additions & 108 deletions

File tree

evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheValueSerde.java

Lines changed: 24 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,20 @@
1717
*
1818
* <pre>
1919
* [byte 0: magic 0x0C][byte 1: reserved/version 0x00]
20-
* [int bodyLength]
2120
* [int keyLen][key UTF-8 bytes]
2221
* [int valLen][value bytes]
2322
* [int flags][long ttl][long createTime]
24-
* [... extension fields ...]
23+
* [... optional extension fields appended by newer writers ...]
2524
* </pre>
2625
*
2726
* <ul>
2827
* <li><b>Magic {@code 0x0C}</b> disambiguates from Java {@code ObjectOutputStream} (starts
2928
* {@code 0xAC 0xED}); callers route via {@link #isBinaryFormat(byte[])}.</li>
3029
* <li><b>Reserved/version byte</b> is currently {@code 0x00}, read-and-ignored. Bump only
3130
* for breaking changes (see Upgrades).</li>
32-
* <li><b>bodyLength</b> = byte count of everything that follows it. Enables additive forward
33-
* compatibility — older readers skip any unread bytes inside the declared body, returning
34-
* the EVCacheValue normally.</li>
31+
* <li><b>End of envelope</b> is implicit at {@code bytes.length}. There is no declared body
32+
* length on the wire; bytes past the last known field are treated as extension data for
33+
* additive forward-compat (see Upgrades).</li>
3534
* <li><b>Byte order:</b> big-endian / network, set explicitly on both sides.</li>
3635
* <li><b>Error contract:</b> any corrupt/truncated input returns {@code null} after a WARN
3736
* log identifying the failing field and a (truncated) hex dump of the bytes. Matches
@@ -40,12 +39,13 @@
4039
*
4140
* <h2>Upgrades</h2>
4241
*
43-
* <p><b>Additive optional (non-breaking).</b> Append a new field at the end of the body; the
44-
* writer grows {@code bodyLength} to include it. Newer readers MUST gate each added field
45-
* with {@code buffer.position() < bodyEnd} and supply a default when absent — a new reader
46-
* will encounter items written by an old writer (in cache until TTL expires) that don't
47-
* contain the field. Only works when a graceful default exists. A new <i>required</i> field
48-
* has no acceptable default and is therefore Breaking, not additive.
42+
* <p><b>Additive optional (non-breaking).</b> Append a new field at the end of the envelope,
43+
* after {@code createTime}. Older readers stop after the known fields and never look at the
44+
* extension bytes. Newer readers MUST gate each added field with {@code buffer.hasRemaining()}
45+
* and supply a default when absent — they will encounter items written by old writers
46+
* (in cache until TTL expires) that don't contain the field. Only works when a graceful
47+
* default exists. A new <i>required</i> field has no acceptable default and is therefore
48+
* Breaking, not additive.
4949
*
5050
* <p><b>Breaking</b> (field reorder, type widen, semantic change, new required field):
5151
* rollout MUST be <i>reader-before-writer</i> — items written by an early writer would be
@@ -89,19 +89,17 @@ public static byte[] serialize(EVCacheValue v) {
8989
final byte[] keyBytes = v.getKey().getBytes(StandardCharsets.UTF_8);
9090
final byte[] valueBytes = v.getValue();
9191

92-
final int bodyLength =
93-
Integer.BYTES + keyBytes.length // keyLen + key
94-
+ Integer.BYTES + valueBytes.length // valLen + value
95-
+ Integer.BYTES // flags
96-
+ Long.BYTES // ttl
97-
+ Long.BYTES; // createTime
98-
99-
final int bufferSize = Byte.BYTES + Byte.BYTES + Integer.BYTES + bodyLength;
92+
final int bufferSize =
93+
Byte.BYTES + Byte.BYTES // magic + reserved/version
94+
+ Integer.BYTES + keyBytes.length // keyLen + key
95+
+ Integer.BYTES + valueBytes.length // valLen + value
96+
+ Integer.BYTES // flags
97+
+ Long.BYTES // ttl
98+
+ Long.BYTES; // createTime
10099
final ByteBuffer buffer = ByteBuffer.allocate(bufferSize).order(ByteOrder.BIG_ENDIAN);
101100

102101
buffer.put(BINARY_SERDE_MAGIC_CONSTANT_BYTE);
103102
buffer.put(RESERVED_VERSION_BYTE);
104-
buffer.putInt(bodyLength);
105103

106104
buffer.putInt(keyBytes.length);
107105
buffer.put(keyBytes);
@@ -115,10 +113,10 @@ public static byte[] serialize(EVCacheValue v) {
115113
}
116114

117115
/**
118-
* Decode the binary envelope. Length prefixes are bounds-checked before allocation; corrupt
119-
* or truncated payloads return {@code null} after a WARN log identifying the failing field.
120-
* Extension bytes appended by newer writers (inside {@code bodyLength}) are silently skipped;
121-
* bytes beyond the declared body are treated as corruption.
116+
* Decode the binary envelope. Length prefixes are bounds-checked before allocation. A
117+
* truncated or malformed payload returns {@code null} after a WARN log identifying the
118+
* failing field. Bytes remaining past the known fields are not read — they're reserved for
119+
* additive extension fields appended by newer writers (see Upgrades).
122120
*/
123121
public static EVCacheValue deserialize(byte[] bytes) {
124122
String field = "magic";
@@ -133,18 +131,6 @@ public static EVCacheValue deserialize(byte[] bytes) {
133131
field = "reserved";
134132
buffer.get();
135133

136-
field = "bodyLength";
137-
final int bodyLength = buffer.getInt();
138-
if (bodyLength < 0 || bodyLength > buffer.remaining()) {
139-
logCorruption(bytes, "Invalid bodyLength: " + bodyLength + ", remaining=" + buffer.remaining());
140-
return null;
141-
}
142-
// Cap reads to the declared body. Field reads that would overrun are caught as
143-
// BufferUnderflowException; extension bytes are skipped explicitly below.
144-
final int originalLimit = buffer.limit();
145-
final int bodyEnd = buffer.position() + bodyLength;
146-
buffer.limit(bodyEnd);
147-
148134
field = "keyLength";
149135
final int keyLength = buffer.getInt();
150136
if (keyLength < 0 || keyLength > buffer.remaining()) {
@@ -173,15 +159,8 @@ public static EVCacheValue deserialize(byte[] bytes) {
173159
field = "createTime";
174160
final long createTime = buffer.getLong();
175161

176-
// Skip any forward-compat extension bytes inside the declared body.
177-
buffer.position(bodyEnd);
178-
179-
// Bytes beyond the declared body weren't claimed by the writer — corruption.
180-
buffer.limit(originalLimit);
181-
if (buffer.remaining() != 0) {
182-
logCorruption(bytes, "trailing " + buffer.remaining() + " bytes beyond declared bodyLength");
183-
return null;
184-
}
162+
// Any remaining bytes are forward-compat extension fields a newer writer appended;
163+
// an older reader (this one) leaves them unread.
185164

186165
return new EVCacheValue(key, valueBytes, flags, ttl, createTime);
187166
} catch (BufferUnderflowException e) {

evcache-core/src/test/java/com/netflix/evcache/pool/EVCacheValueSerdeTest.java

Lines changed: 16 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -220,73 +220,32 @@ public void testDecodeTruncatedBinaryReturnsNull() {
220220
}
221221

222222
@Test
223-
public void testDecodeBinaryWithBogusBodyLengthReturnsNull() {
224-
// Magic + reserved + wildly oversized bodyLength. Bounds check rejects.
223+
public void testDecodeBinaryWithBogusKeyLengthReturnsNull() {
224+
// Magic + reserved + wildly oversized keyLength. Bounds check rejects.
225225
byte[] bytes = new byte[2 + Integer.BYTES];
226226
bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE;
227227
bytes[1] = 0x00;
228-
bytes[2] = (byte) 0x7F;
229-
bytes[3] = (byte) 0xFF;
230-
bytes[4] = (byte) 0xFF;
231-
bytes[5] = (byte) 0xFF;
232-
CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE);
233-
assertThat(defaultTranscoder().decode(cd)).isNull();
234-
}
235-
236-
@Test
237-
public void testDecodeBinaryWithNegativeBodyLengthReturnsNull() {
238-
// Magic + reserved + negative bodyLength prefix.
239-
byte[] bytes = new byte[2 + Integer.BYTES];
240-
bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE;
241-
bytes[1] = 0x00;
242-
bytes[2] = (byte) 0xFF;
243-
bytes[3] = (byte) 0xFF;
244-
bytes[4] = (byte) 0xFF;
245-
bytes[5] = (byte) 0xFF;
246-
CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE);
247-
assertThat(defaultTranscoder().decode(cd)).isNull();
248-
}
249-
250-
@Test
251-
public void testDecodeBinaryWithBogusKeyLengthInsideValidBodyReturnsNull() {
252-
// Valid bodyLength claiming 4 bytes of body, but those 4 bytes encode a bogus keyLength.
253-
byte[] bytes = new byte[2 + Integer.BYTES + Integer.BYTES];
254-
bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE;
255-
bytes[1] = 0x00;
256228
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
257-
bb.putInt(2, Integer.BYTES); // bodyLength = 4 (just the keyLength field)
258-
bb.putInt(6, 0x7FFFFFFF); // bogus keyLength
229+
bb.putInt(2, 0x7FFFFFFF);
259230
CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE);
260231
assertThat(defaultTranscoder().decode(cd)).isNull();
261232
}
262233

263234
@Test
264-
public void testDecodeBinaryWithNegativeKeyLengthInsideValidBodyReturnsNull() {
265-
byte[] bytes = new byte[2 + Integer.BYTES + Integer.BYTES];
235+
public void testDecodeBinaryWithNegativeKeyLengthReturnsNull() {
236+
byte[] bytes = new byte[2 + Integer.BYTES];
266237
bytes[0] = EVCacheValueSerde.BINARY_SERDE_MAGIC_CONSTANT_BYTE;
267238
bytes[1] = 0x00;
268239
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
269-
bb.putInt(2, Integer.BYTES); // bodyLength = 4
270-
bb.putInt(6, -1); // negative keyLength
240+
bb.putInt(2, -1);
271241
CachedData cd = new CachedData(SERIALIZED, bytes, CachedData.MAX_SIZE);
272242
assertThat(defaultTranscoder().decode(cd)).isNull();
273243
}
274244

275-
@Test
276-
public void testDecodeBinaryWithTrailingBytesBeyondBodyReturnsNull() {
277-
// A well-formed envelope with extra bytes appended beyond the declared bodyLength is
278-
// rejected — the writer didn't intend those bytes, so they indicate corruption (e.g. a
279-
// length prefix that decoded short, leaving the missing payload at the tail).
280-
byte[] full = binaryTranscoder().encode(typical()).getData();
281-
byte[] withTrailing = Arrays.copyOf(full, full.length + 3);
282-
CachedData cd = new CachedData(SERIALIZED, withTrailing, CachedData.MAX_SIZE);
283-
assertThat(defaultTranscoder().decode(cd)).isNull();
284-
}
285-
286245
// ---- 8. Forward compatibility trip-wire: pinned v0 payload must always decode ----
287246
//
288247
// If this test starts failing after a change to EVCacheValueSerde.deserialize(), someone
289-
// likely added a required field without the `buffer.position() < bodyEnd` guard. See the
248+
// likely added a required field without the `buffer.hasRemaining()` guard. See the
290249
// "Additive optional" section of EVCacheValueSerde's Javadoc — a future reader must be
291250
// able to decode the v0 payload below (which an old writer would have produced) for as
292251
// long as items written by old writers can still be in any cache.
@@ -297,7 +256,6 @@ public void testV0PayloadDecodesAsOptionalAdditiveFieldTripWire() {
297256
byte[] v0Bytes = {
298257
(byte) 0x0C, // magic
299258
(byte) 0x00, // reserved/version
300-
0x00, 0x00, 0x00, 0x1E, // bodyLength = 30
301259
0x00, 0x00, 0x00, 0x01, // keyLength = 1
302260
(byte) 'k',
303261
0x00, 0x00, 0x00, 0x01, // valueLength = 1
@@ -312,30 +270,25 @@ public void testV0PayloadDecodesAsOptionalAdditiveFieldTripWire() {
312270
EVCacheValue expected = new EVCacheValue("k", new byte[] {0x76}, 1, 60L, 42L);
313271
assertThat(out)
314272
.as("Pinned v0 payload must decode cleanly. If it doesn't, a required field was "
315-
+ "likely added to deserialize() without the buffer.position() < bodyEnd "
316-
+ "guard. See EVCacheValueSerde Javadoc 'Additive optional'.")
273+
+ "likely added to deserialize() without the buffer.hasRemaining() guard. "
274+
+ "See EVCacheValueSerde Javadoc 'Additive optional'.")
317275
.isEqualTo(expected);
318276
}
319277

320-
// ---- 9. Forward compatibility: newer-writer extension bytes inside bodyLength ----
278+
// ---- 9. Forward compatibility: newer-writer extension bytes past createTime ----
321279
//
322-
// A writer that adds new fields after createTime grows bodyLength to include them. An
323-
// older reader (this one) should skip those extra bytes and return the EVCacheValue it
324-
// does know how to decode — NOT a corruption event.
280+
// A writer that adds new optional fields appends them after createTime. An older reader
281+
// (this one) reads its known fields, leaves the extension bytes unread, and returns the
282+
// EVCacheValue it does know how to decode — NOT a corruption event.
325283

326284
@Test
327285
public void testDecodeBinaryWithFutureExtensionFieldsIsForwardCompat() {
328286
EVCacheValue v = typical();
329287
byte[] validBytes = binaryTranscoder().encode(v).getData();
330288

331-
// Layout: [magic][reserved][bodyLength int][body...]. Append 3 extension bytes after
332-
// the body and bump the bodyLength field to claim them — what a future writer would do.
333-
final int extensionSize = 3;
334-
byte[] withExtension = new byte[validBytes.length + extensionSize];
335-
System.arraycopy(validBytes, 0, withExtension, 0, validBytes.length);
336-
ByteBuffer bb = ByteBuffer.wrap(withExtension).order(ByteOrder.BIG_ENDIAN);
337-
int currentBodyLength = bb.getInt(2);
338-
bb.putInt(2, currentBodyLength + extensionSize);
289+
// Append 3 extension bytes past the end of the v0 envelope — what a future writer
290+
// would do. End of envelope is implicit at bytes.length, no header to update.
291+
byte[] withExtension = Arrays.copyOf(validBytes, validBytes.length + 3);
339292

340293
CachedData cd = new CachedData(SERIALIZED, withExtension, CachedData.MAX_SIZE);
341294
Object out = defaultTranscoder().decode(cd);

0 commit comments

Comments
 (0)